From f8fa379732a5d56449e4a51a247e7f44c429a98c Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 10 Mar 2026 08:22:24 -0400 Subject: [PATCH 001/141] DB migration: Add `require_all` column to installers labels tables (#41279) --- ...stallerVPPAppInHouseAppIncludeAllLabels.go | 33 +++++++++++++++++++ ...erVPPAppInHouseAppIncludeAllLabels_test.go | 13 ++++++++ server/datastore/mysql/schema.sql | 13 +++++--- 3 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 server/datastore/mysql/migrations/tables/20260309182329_AddSoftwareInstallerVPPAppInHouseAppIncludeAllLabels.go create mode 100644 server/datastore/mysql/migrations/tables/20260309182329_AddSoftwareInstallerVPPAppInHouseAppIncludeAllLabels_test.go diff --git a/server/datastore/mysql/migrations/tables/20260309182329_AddSoftwareInstallerVPPAppInHouseAppIncludeAllLabels.go b/server/datastore/mysql/migrations/tables/20260309182329_AddSoftwareInstallerVPPAppInHouseAppIncludeAllLabels.go new file mode 100644 index 00000000000..7419e9aae7b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260309182329_AddSoftwareInstallerVPPAppInHouseAppIncludeAllLabels.go @@ -0,0 +1,33 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260309182329, Down_20260309182329) +} + +func Up_20260309182329(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE in_house_app_labels ADD COLUMN require_all BOOL NOT NULL DEFAULT false`) + if err != nil { + return fmt.Errorf("failed to add require_all to in_house_app_labels: %w", err) + } + + _, err = tx.Exec(`ALTER TABLE software_installer_labels ADD COLUMN require_all BOOL NOT NULL DEFAULT false`) + if err != nil { + return fmt.Errorf("failed to add require_all to software_installer_labels: %w", err) + } + + _, err = tx.Exec(`ALTER TABLE vpp_app_team_labels ADD COLUMN require_all BOOL NOT NULL DEFAULT false`) + if err != nil { + return fmt.Errorf("failed to add require_all to vpp_app_team_labels: %w", err) + } + + return nil +} + +func Down_20260309182329(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20260309182329_AddSoftwareInstallerVPPAppInHouseAppIncludeAllLabels_test.go b/server/datastore/mysql/migrations/tables/20260309182329_AddSoftwareInstallerVPPAppInHouseAppIncludeAllLabels_test.go new file mode 100644 index 00000000000..3490e5a45f1 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260309182329_AddSoftwareInstallerVPPAppInHouseAppIncludeAllLabels_test.go @@ -0,0 +1,13 @@ +package tables + +import "testing" + +func TestUp_20260309182329(t *testing.T) { + db := applyUpToPrev(t) + + // Just a new column, so no logic to test here. + // Leaving it in because it's nice to validate that the migration applies successfully. + + // Apply current migration. + applyNext(t, db) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index ffb8afdc2f2..3de114d5761 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1224,12 +1224,13 @@ CREATE TABLE `in_house_app_labels` ( `exclude` tinyint(1) NOT NULL DEFAULT '0', `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `require_all` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `id_in_house_app_labels_in_house_app_id_label_id` (`in_house_app_id`,`label_id`), KEY `label_id` (`label_id`), CONSTRAINT `in_house_app_labels_ibfk_1` FOREIGN KEY (`in_house_app_id`) REFERENCES `in_house_apps` (`id`) ON DELETE CASCADE, CONSTRAINT `in_house_app_labels_ibfk_2` FOREIGN KEY (`label_id`) REFERENCES `labels` (`id`) ON DELETE RESTRICT -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -1784,9 +1785,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=491 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=492 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217181748,1,'2020-01-01 01:01:01'),(482,20260217200906,1,'2020-01-01 01:01:01'),(483,20260218165545,1,'2020-01-01 01:01:01'),(484,20260218175704,1,'2020-01-01 01:01:01'),(485,20260223000000,1,'2020-01-01 01:01:01'),(486,20260225143121,1,'2020-01-01 01:01:01'),(487,20260226182000,1,'2020-01-01 01:01:01'),(488,20260228115022,1,'2020-01-01 01:01:01'),(489,20260303180102,1,'2020-01-01 01:01:01'),(490,20260306120000,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217181748,1,'2020-01-01 01:01:01'),(482,20260217200906,1,'2020-01-01 01:01:01'),(483,20260218165545,1,'2020-01-01 01:01:01'),(484,20260218175704,1,'2020-01-01 01:01:01'),(485,20260223000000,1,'2020-01-01 01:01:01'),(486,20260225143121,1,'2020-01-01 01:01:01'),(487,20260226182000,1,'2020-01-01 01:01:01'),(488,20260228115022,1,'2020-01-01 01:01:01'),(489,20260303180102,1,'2020-01-01 01:01:01'),(490,20260306120000,1,'2020-01-01 01:01:01'),(491,20260309182329,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -2661,12 +2662,13 @@ CREATE TABLE `software_installer_labels` ( `exclude` tinyint(1) NOT NULL DEFAULT '0', `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `require_all` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_software_installer_labels_software_installer_id_label_id` (`software_installer_id`,`label_id`), KEY `label_id` (`label_id`), CONSTRAINT `software_installer_labels_ibfk_1` FOREIGN KEY (`software_installer_id`) REFERENCES `software_installers` (`id`) ON DELETE CASCADE, CONSTRAINT `software_installer_labels_ibfk_2` FOREIGN KEY (`label_id`) REFERENCES `labels` (`id`) ON DELETE RESTRICT -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -2924,12 +2926,13 @@ CREATE TABLE `vpp_app_team_labels` ( `exclude` tinyint(1) NOT NULL DEFAULT '0', `created_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `require_all` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_vpp_app_team_labels_vpp_app_team_id_label_id` (`vpp_app_team_id`,`label_id`), KEY `label_id` (`label_id`), CONSTRAINT `vpp_app_team_labels_ibfk_1` FOREIGN KEY (`vpp_app_team_id`) REFERENCES `vpp_apps_teams` (`id`) ON DELETE CASCADE, CONSTRAINT `vpp_app_team_labels_ibfk_2` FOREIGN KEY (`label_id`) REFERENCES `labels` (`id`) ON DELETE RESTRICT -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; From ba04887100b0fbc50efa87cdb7933f15f9b5ad92 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Wed, 18 Mar 2026 13:27:53 -0400 Subject: [PATCH 002/141] Backend: Support labels_include_all for installers/apps (#41324) **Related issue:** Resolves #40721 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [ ] QA'd all new/changed functionality manually I (Martin) did test `labels_include_all` for FMA, custom installer, IPA and VPP apps, and it seemed to all work great for gitops apply and gitops generate, **except for VPP apps** which seem to have 2 important pre-existing bugs, see https://github.com/fleetdm/fleet/issues/40723#issuecomment-4041780707 ## New Fleet configuration settings - [ ] Verified that the setting is exported via `fleetctl generate-gitops` - [ ] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [ ] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [ ] Verified that any relevant UI is disabled when GitOps mode is enabled --------- Co-authored-by: Jahziel Villasana-Espinoza --- ...-support-labels-include-all-for-installers | 1 + cmd/fleetctl/fleetctl/generate_gitops.go | 8 + cmd/fleetctl/fleetctl/generate_gitops_test.go | 27 +- cmd/fleetctl/fleetctl/gitops.go | 24 +- .../generateGitops/expectedTeamSoftware.yaml | 9 + .../fleets/team-a-thumbsup.yml | 9 + ...m_software_installer_valid_include_all.yml | 19 + ...m_software_installer_valid_include_all.yml | 28 ++ .../team_vpp_valid_app_labels_include_all.yml | 20 + ..._enterprise_integration_deprecated_test.go | 37 ++ .../gitops_enterprise_integration_test.go | 238 ++++++++++ .../integrationtest/gitops/software_test.go | 12 +- ee/server/service/in_house_apps.go | 13 +- ee/server/service/maintained_apps.go | 11 +- ee/server/service/maintained_apps_test.go | 4 +- ee/server/service/software_installers.go | 105 +++-- ee/server/service/software_title_icons.go | 3 + ee/server/service/vpp.go | 33 +- ee/server/service/vpp_test.go | 4 + pkg/spec/gitops.go | 33 +- pkg/spec/gitops_test.go | 14 +- server/datastore/mysql/in_house_apps.go | 47 +- server/datastore/mysql/software.go | 184 ++++++-- server/datastore/mysql/software_installers.go | 85 +++- server/datastore/mysql/software_test.go | 121 ++++- .../datastore/mysql/software_title_icons.go | 34 +- .../mysql/software_title_icons_test.go | 34 +- server/datastore/mysql/vpp.go | 96 +++- server/datastore/mysql/vpp_test.go | 6 + server/fleet/activities.go | 6 + server/fleet/scripts.go | 1 + server/fleet/service.go | 2 +- server/fleet/software.go | 2 + server/fleet/software_installer.go | 24 +- server/fleet/software_title_icons.go | 1 + server/fleet/teams.go | 3 +- server/fleet/vpp.go | 13 +- server/mock/service/service_mock.go | 6 +- server/service/client.go | 2 + server/service/integration_enterprise_test.go | 423 ++++++++++++++++-- server/service/integration_mdm_test.go | 36 +- server/service/maintained_apps.go | 6 +- server/service/software_installers.go | 32 +- server/service/software_installers_test.go | 30 +- server/service/software_title_icons_test.go | 4 + server/service/testing_client.go | 10 + server/service/vpp.go | 4 + .../generated_files/teamconfig.txt | 3 + 48 files changed, 1628 insertions(+), 239 deletions(-) create mode 100644 changes/41324-support-labels-include-all-for-installers create mode 100644 cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_valid_include_all.yml create mode 100644 cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_valid_include_all.yml create mode 100644 cmd/fleetctl/fleetctl/testdata/gitops/team_vpp_valid_app_labels_include_all.yml diff --git a/changes/41324-support-labels-include-all-for-installers b/changes/41324-support-labels-include-all-for-installers new file mode 100644 index 00000000000..9d8b7b50d5d --- /dev/null +++ b/changes/41324-support-labels-include-all-for-installers @@ -0,0 +1 @@ +- Added support for `labels_include_all` conditional scoping for software installers and apps. diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 7f47584f16a..b45f73a0056 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -1831,6 +1831,10 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, labels = softwareTitle.SoftwarePackage.LabelsExcludeAny labelKey = "labels_exclude_any" } + if len(softwareTitle.SoftwarePackage.LabelsIncludeAll) > 0 { + labels = softwareTitle.SoftwarePackage.LabelsIncludeAll + labelKey = "labels_include_all" + } if _, exists := setupSoftwareBySoftwareTitle[softwareTitle.ID]; exists { softwareSpec["setup_experience"] = true } @@ -1845,6 +1849,10 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, labels = softwareTitle.AppStoreApp.LabelsExcludeAny labelKey = "labels_exclude_any" } + if len(softwareTitle.AppStoreApp.LabelsIncludeAll) > 0 { + labels = softwareTitle.AppStoreApp.LabelsIncludeAll + labelKey = "labels_include_all" + } if _, exists := setupSoftwareByPlatformAndAppID[platformAndAppID]; exists { softwareSpec["setup_experience"] = true } diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index 9f686bffaaf..0e31570c6cb 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -451,9 +451,15 @@ func (MockClient) GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTi return &fleet.SoftwareTitle{ ID: 6, AppStoreApp: &fleet.VPPAppStoreApp{ - VPPAppID: fleet.VPPAppID{AdamID: "com.example.setup-experience-software", Platform: fleet.AndroidPlatform}, - LabelsExcludeAny: []fleet.SoftwareScopeLabel{}, - SelfService: true, + VPPAppID: fleet.VPPAppID{AdamID: "com.example.setup-experience-software", Platform: fleet.AndroidPlatform}, + LabelsIncludeAll: []fleet.SoftwareScopeLabel{ + { + LabelName: "Label C", + }, { + LabelName: "Label D", + }, + }, + SelfService: true, }, IconUrl: ptr.String("/api/icon3.png"), }, nil @@ -466,6 +472,13 @@ func (MockClient) GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTi AppStoreApp: &fleet.VPPAppStoreApp{ VPPAppID: fleet.VPPAppID{AdamID: "com.example.ios-auto-update", Platform: fleet.IOSPlatform}, SelfService: false, + LabelsIncludeAll: []fleet.SoftwareScopeLabel{ + { + LabelName: "Label C", + }, { + LabelName: "Label D", + }, + }, }, IconUrl: ptr.String("/api/icon4.png"), SoftwareAutoUpdateConfig: fleet.SoftwareAutoUpdateConfig{ @@ -502,6 +515,14 @@ func (MockClient) GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTi ID: 9, Name: "My Windows FMA", SoftwarePackage: &fleet.SoftwareInstaller{ + LabelsIncludeAll: []fleet.SoftwareScopeLabel{ + { + LabelName: "Label A", + }, + { + LabelName: "Label B", + }, + }, InstallScript: "install", UninstallScript: "uninstall", SelfService: true, diff --git a/cmd/fleetctl/fleetctl/gitops.go b/cmd/fleetctl/fleetctl/gitops.go index e15c79f968e..94ae537e1e9 100644 --- a/cmd/fleetctl/fleetctl/gitops.go +++ b/cmd/fleetctl/fleetctl/gitops.go @@ -763,10 +763,16 @@ func getLabelUsage(config *spec.GitOps) (map[string][]LabelUsage, error) { } if len(softwarePackage.LabelsExcludeAny) > 0 { if len(labels) > 0 { - return nil, fmt.Errorf("Software package '%s' has multiple label keys; please choose one of `labels_include_any`, `labels_exclude_any`.", softwarePackage.URL) + return nil, fmt.Errorf("Software package '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", softwarePackage.URL) } labels = softwarePackage.LabelsExcludeAny } + if len(softwarePackage.LabelsIncludeAll) > 0 { + if len(labels) > 0 { + return nil, fmt.Errorf("Software package '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", softwarePackage.URL) + } + labels = softwarePackage.LabelsIncludeAll + } updateLabelUsage(labels, softwarePackage.URL, "Software Package", result) } @@ -778,10 +784,16 @@ func getLabelUsage(config *spec.GitOps) (map[string][]LabelUsage, error) { } if len(vppApp.LabelsExcludeAny) > 0 { if len(labels) > 0 { - return nil, fmt.Errorf("App Store App '%s' has multiple label keys; please choose one of `labels_include_any`, `labels_exclude_any`.", vppApp.AppStoreID) + return nil, fmt.Errorf("App Store App '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", vppApp.AppStoreID) } labels = vppApp.LabelsExcludeAny } + if len(vppApp.LabelsIncludeAll) > 0 { + if len(labels) > 0 { + return nil, fmt.Errorf("App Store App '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", vppApp.AppStoreID) + } + labels = vppApp.LabelsIncludeAll + } updateLabelUsage(labels, vppApp.AppStoreID, "App Store App", result) } @@ -792,10 +804,16 @@ func getLabelUsage(config *spec.GitOps) (map[string][]LabelUsage, error) { } if len(maintainedApp.LabelsExcludeAny) > 0 { if len(labels) > 0 { - return nil, fmt.Errorf("Fleet Maintained App '%s' has multiple label keys; please choose one of `labels_include_any`, `labels_exclude_any`.", maintainedApp.Slug) + return nil, fmt.Errorf("Fleet Maintained App '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", maintainedApp.Slug) } labels = maintainedApp.LabelsExcludeAny } + if len(maintainedApp.LabelsIncludeAll) > 0 { + if len(labels) > 0 { + return nil, fmt.Errorf("Fleet Maintained App '%s' has multiple label keys; please choose one of `labels_include_all`, `labels_include_any`, `labels_exclude_any`.", maintainedApp.Slug) + } + labels = maintainedApp.LabelsIncludeAll + } updateLabelUsage(labels, maintainedApp.Slug, "Fleet Maintained App", result) } diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml index 72376119d0c..6a2c1a1904e 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedTeamSoftware.yaml @@ -30,6 +30,9 @@ fleet_maintained_apps: path: ../lib/some-team/queries/my-fma-darwin-preinstallquery.yml self_service: true - slug: fma2/windows + labels_include_all: + - Label A + - Label B self_service: true app_store_apps: - app_store_id: com.example.team-software @@ -42,10 +45,16 @@ app_store_apps: self_service: true platform: darwin - app_store_id: com.example.setup-experience-software + labels_include_all: + - Label C + - Label D platform: android self_service: true setup_experience: true - app_store_id: com.example.ios-auto-update + labels_include_all: + - Label C + - Label D auto_update_enabled: true auto_update_window_start: "01:00" auto_update_window_end: "03:00" diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml index 0da799c2faf..49b0a9278ef 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml @@ -113,6 +113,9 @@ software: - app_store_id: com.example.setup-experience-software icon: path: "../lib/team-a-πŸ‘/icons/my-setup-experience-app-android-icon.png" + labels_include_all: + - Label C + - Label D platform: android self_service: true setup_experience: true @@ -122,6 +125,9 @@ software: auto_update_window_start: "01:00" icon: path: "../lib/team-a-πŸ‘/icons/my-ios-auto-update-app-ios-icon.png" + labels_include_all: + - Label C + - Label D platform: ios fleet_maintained_apps: - categories: @@ -140,6 +146,9 @@ software: slug: fma1/darwin - icon: path: "../lib/team-a-πŸ‘/icons/my-windows-fma-windows-icon.png" + labels_include_all: + - Label A + - Label B self_service: true slug: fma2/windows packages: diff --git a/cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_valid_include_all.yml b/cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_valid_include_all.yml new file mode 100644 index 00000000000..95a39e8ae26 --- /dev/null +++ b/cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_valid_include_all.yml @@ -0,0 +1,19 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby.yml + post_install_script: + path: lib/post_install_ruby.sh + uninstall_script: + path: lib/uninstall_ruby.sh + labels_include_all: + - a + - b + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_valid_include_all.yml b/cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_valid_include_all.yml new file mode 100644 index 00000000000..5ffe5af2346 --- /dev/null +++ b/cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_valid_include_all.yml @@ -0,0 +1,28 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ruby.deb + install_script: + path: lib/install_ruby.sh + pre_install_query: + path: lib/query_ruby_apply.yml + post_install_script: + path: lib/post_install_ruby.sh + labels_include_all: + - a + - b + - url: ${SOFTWARE_INSTALLER_URL}/other.deb + self_service: true diff --git a/cmd/fleetctl/fleetctl/testdata/gitops/team_vpp_valid_app_labels_include_all.yml b/cmd/fleetctl/fleetctl/testdata/gitops/team_vpp_valid_app_labels_include_all.yml new file mode 100644 index 00000000000..5486e9db6f5 --- /dev/null +++ b/cmd/fleetctl/fleetctl/testdata/gitops/team_vpp_valid_app_labels_include_all.yml @@ -0,0 +1,20 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + app_store_apps: + - app_store_id: "1" + labels_include_all: + - "label 1" + - "label 2" diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_deprecated_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_deprecated_test.go index db515c4b1da..deb40eb803e 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_deprecated_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_deprecated_test.go @@ -329,6 +329,10 @@ team_settings: withLabelsExcludeAny = ` labels_exclude_any: - Label1 +` + withLabelsIncludeAll = ` + labels_include_all: + - Label1 ` ) @@ -392,6 +396,34 @@ team_settings: require.Len(t, meta.LabelsExcludeAny, 1) require.Equal(t, "Label1", meta.LabelsExcludeAny[0].LabelName) + // switch both to labels_include_all + err = os.WriteFile(noTeamFilePath, fmt.Appendf(nil, noTeamTemplate, withLabelsIncludeAll), 0o644) + require.NoError(t, err) + err = os.WriteFile(teamFile.Name(), fmt.Appendf(nil, teamTemplate, withLabelsIncludeAll, teamName), 0o644) + require.NoError(t, err) + + // Apply configs + s.assertDryRunOutputWithDeprecation(t, fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"}), true) + s.assertRealRunOutputWithDeprecation(t, fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()}), true) + + // the installer is now scoped by labels_include_all for no team + meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false) + require.NoError(t, err) + require.Empty(t, meta.LabelsIncludeAny) + require.Empty(t, meta.LabelsExcludeAny) + require.Len(t, meta.LabelsIncludeAll, 1) + require.Equal(t, "Label1", meta.LabelsIncludeAll[0].LabelName) + + // the installer is now scoped by labels_include_all for team + meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false) + require.NoError(t, err) + require.Empty(t, meta.LabelsIncludeAny) + require.Empty(t, meta.LabelsExcludeAny) + require.Len(t, meta.LabelsIncludeAll, 1) + require.Equal(t, "Label1", meta.LabelsIncludeAll[0].LabelName) + // remove the label conditions err = os.WriteFile(noTeamFilePath, fmt.Appendf(nil, noTeamTemplate, emptyLabelsIncludeAny), 0o644) require.NoError(t, err) @@ -411,6 +443,7 @@ team_settings: require.Equal(t, noTeamTitleID, *meta.TitleID) require.Len(t, meta.LabelsExcludeAny, 0) require.Len(t, meta.LabelsIncludeAny, 0) + require.Len(t, meta.LabelsIncludeAll, 0) meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false) require.NoError(t, err) @@ -418,6 +451,7 @@ team_settings: require.Equal(t, teamTitleID, *meta.TitleID) require.Len(t, meta.LabelsExcludeAny, 0) require.Len(t, meta.LabelsIncludeAny, 0) + require.Len(t, meta.LabelsIncludeAll, 0) } func (s *enterpriseIntegrationGitopsTestSuite) TestNoTeamWebhookSettingsDeprecated() { @@ -944,6 +978,7 @@ team_settings: require.True(t, meta.SelfService) require.Empty(t, meta.LabelsExcludeAny) require.Empty(t, meta.LabelsIncludeAny) + require.Empty(t, meta.LabelsIncludeAll) } require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources) require.ElementsMatch(t, []string{"ios", "ipados"}, platforms) @@ -1000,6 +1035,7 @@ team_settings: require.NoError(t, err) require.False(t, meta.SelfService) require.Empty(t, meta.LabelsExcludeAny) + require.Empty(t, meta.LabelsIncludeAll) require.Len(t, meta.LabelsIncludeAny, 1) require.Equal(t, lbl.ID, meta.LabelsIncludeAny[0].LabelID) require.Empty(t, meta.InstallScript) // install script should be ignored for ipa apps @@ -1039,6 +1075,7 @@ team_settings: require.False(t, meta.SelfService) require.Empty(t, meta.LabelsExcludeAny) require.Empty(t, meta.LabelsIncludeAny) + require.Empty(t, meta.LabelsIncludeAll) } require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources) require.ElementsMatch(t, []string{"ios", "ipados"}, platforms) diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go index 49b98036672..cfa7774e480 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go @@ -916,6 +916,10 @@ software: ` emptyLabelsIncludeAny = ` labels_include_any: +` + withLabelsIncludeAll = ` + labels_include_all: + - Label1 ` teamTemplate = ` controls: @@ -996,6 +1000,34 @@ settings: require.Len(t, meta.LabelsExcludeAny, 1) require.Equal(t, "Label1", meta.LabelsExcludeAny[0].LabelName) + // switch both to labels_include_all + err = os.WriteFile(noTeamFilePath, fmt.Appendf(nil, noTeamTemplate, withLabelsIncludeAll), 0o644) + require.NoError(t, err) + err = os.WriteFile(teamFile.Name(), fmt.Appendf(nil, teamTemplate, withLabelsIncludeAll, teamName), 0o644) + require.NoError(t, err) + + // Apply configs + s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), "--dry-run"})) + s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name()})) + + // the installer is now scoped by labels_include_all for no team + meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false) + require.NoError(t, err) + require.Empty(t, meta.LabelsIncludeAny) + require.Empty(t, meta.LabelsExcludeAny) + require.Len(t, meta.LabelsIncludeAll, 1) + require.Equal(t, "Label1", meta.LabelsIncludeAll[0].LabelName) + + // the installer is now scoped by labels_include_all for team + meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false) + require.NoError(t, err) + require.Empty(t, meta.LabelsIncludeAny) + require.Empty(t, meta.LabelsExcludeAny) + require.Len(t, meta.LabelsIncludeAll, 1) + require.Equal(t, "Label1", meta.LabelsIncludeAll[0].LabelName) + // remove the label conditions err = os.WriteFile(noTeamFilePath, []byte(fmt.Sprintf(noTeamTemplate, emptyLabelsIncludeAny)), 0o644) require.NoError(t, err) @@ -1015,6 +1047,7 @@ settings: require.Equal(t, noTeamTitleID, *meta.TitleID) require.Len(t, meta.LabelsExcludeAny, 0) require.Len(t, meta.LabelsIncludeAny, 0) + require.Len(t, meta.LabelsIncludeAll, 0) meta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false) require.NoError(t, err) @@ -1022,6 +1055,7 @@ settings: require.Equal(t, teamTitleID, *meta.TitleID) require.Len(t, meta.LabelsExcludeAny, 0) require.Len(t, meta.LabelsIncludeAny, 0) + require.Len(t, meta.LabelsIncludeAll, 0) } func (s *enterpriseIntegrationGitopsTestSuite) TestDeletingNoTeamYAML() { @@ -2191,6 +2225,7 @@ settings: require.True(t, meta.SelfService) require.Empty(t, meta.LabelsExcludeAny) require.Empty(t, meta.LabelsIncludeAny) + require.Empty(t, meta.LabelsIncludeAll) } require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources) require.ElementsMatch(t, []string{"ios", "ipados"}, platforms) @@ -2247,6 +2282,7 @@ settings: require.NoError(t, err) require.False(t, meta.SelfService) require.Empty(t, meta.LabelsExcludeAny) + require.Empty(t, meta.LabelsIncludeAll) require.Len(t, meta.LabelsIncludeAny, 1) require.Equal(t, lbl.ID, meta.LabelsIncludeAny[0].LabelID) require.Empty(t, meta.InstallScript) // install script should be ignored for ipa apps @@ -2286,6 +2322,7 @@ settings: require.False(t, meta.SelfService) require.Empty(t, meta.LabelsExcludeAny) require.Empty(t, meta.LabelsIncludeAny) + require.Empty(t, meta.LabelsIncludeAll) } require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources) require.ElementsMatch(t, []string{"ios", "ipados"}, platforms) @@ -3999,3 +4036,204 @@ name: %s require.NoError(t, err) require.Len(t, titles, 0) } + +// TestFMALabelsIncludeAll tests that labels_include_all is correctly applied and +// cleared for Fleet Maintained Apps via gitops, for both no-team and a specific team. +func (s *enterpriseIntegrationGitopsTestSuite) TestFMALabelsIncludeAll() { + t := s.T() + ctx := context.Background() + + user := s.createGitOpsUser(t) + fleetctlConfig := s.createFleetctlConfig(t, user) + + lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1" + t.Name(), Query: "SELECT 1"}) + require.NoError(t, err) + require.NotZero(t, lbl.ID) + + slug := fmt.Sprintf("foo%s/darwin", t.Name()) + + const ( + globalTemplate = ` +agent_options: +controls: +org_settings: + server_settings: + server_url: $FLEET_URL + org_info: + org_name: Fleet + secrets: +policies: +reports: +` + noTeamTemplate = `name: No team +controls: +policies: +software: + fleet_maintained_apps: + - slug: %s +%s +` + teamTemplate = ` +controls: +software: + fleet_maintained_apps: + - slug: %s +%s +reports: +policies: +agent_options: +name: %s +settings: + secrets: [{"secret":"enroll_secret"}] +` + ) + const noLabels = "" + + withLabelsIncludeAll := fmt.Sprintf(` + labels_include_all: + - %s +`, lbl.Name) + + globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFile.WriteString(globalTemplate) + require.NoError(t, err) + err = globalFile.Close() + require.NoError(t, err) + + noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = fmt.Fprintf(noTeamFile, noTeamTemplate, slug, withLabelsIncludeAll) + require.NoError(t, err) + err = noTeamFile.Close() + require.NoError(t, err) + noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml") + err = os.Rename(noTeamFile.Name(), noTeamFilePath) + require.NoError(t, err) + + teamName := uuid.NewString() + teamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = fmt.Fprintf(teamFile, teamTemplate, slug, withLabelsIncludeAll, teamName) + require.NoError(t, err) + err = teamFile.Close() + require.NoError(t, err) + + // Set the required environment variables + t.Setenv("FLEET_URL", s.Server.URL) + testing_utils.StartSoftwareInstallerServer(t) + + // Mock server to serve FMA installer bytes + installerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("foo")) + })) + defer installerServer.Close() + + // Mock server to serve the FMA manifest + manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + versions := []*ma.FMAManifestApp{ + { + Version: "1.0", + Queries: ma.FMAQueries{ + Exists: "SELECT 1 FROM osquery_info;", + }, + InstallerURL: installerServer.URL + "/foo.pkg", + InstallScriptRef: "fooscript", + UninstallScriptRef: "fooscript", + SHA256: "no_check", + }, + } + manifest := ma.FMAManifestFile{ + Versions: versions, + Refs: map[string]string{"fooscript": "echo hello"}, + } + err := json.NewEncoder(w).Encode(manifest) + require.NoError(t, err) + })) + defer manifestServer.Close() + + dev_mode.SetOverride("FLEET_DEV_MAINTAINED_APPS_BASE_URL", manifestServer.URL, t) + + // Insert the FMA record so gitops can resolve the slug + mysql.ExecAdhocSQL(t, s.DS, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, + `INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier) + VALUES (?, ?, 'darwin', ?)`, "foo"+t.Name(), slug, `com.example.foo`+t.Name()) + return err + }) + + // Apply configs β€” dry-run first, then real run + s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{ + "gitops", "--config", fleetctlConfig.Name(), + "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), + "--dry-run", + })) + s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{ + "gitops", "--config", fleetctlConfig.Name(), + "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), + })) + + // Retrieve the team so we have its ID + team, err := s.DS.TeamByName(ctx, teamName) + require.NoError(t, err) + + // Locate the FMA installer for no-team and assert labels_include_all is set + noTeamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, + fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)}, + fleet.TeamFilter{User: test.UserAdmin}) + require.NoError(t, err) + require.Len(t, noTeamTitles, 1) + noTeamTitleID := noTeamTitles[0].ID + + noTeamMeta, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false) + require.NoError(t, err) + require.Empty(t, noTeamMeta.LabelsIncludeAny) + require.Empty(t, noTeamMeta.LabelsExcludeAny) + require.Len(t, noTeamMeta.LabelsIncludeAll, 1) + require.Equal(t, lbl.Name, noTeamMeta.LabelsIncludeAll[0].LabelName) + + // Locate the FMA installer for the team and assert labels_include_all is set + teamTitles, _, _, err := s.DS.ListSoftwareTitles(ctx, + fleet.SoftwareTitleListOptions{TeamID: &team.ID}, + fleet.TeamFilter{User: test.UserAdmin}) + require.NoError(t, err) + require.Len(t, teamTitles, 1) + teamTitleID := teamTitles[0].ID + + teamMeta, err := s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false) + require.NoError(t, err) + require.Empty(t, teamMeta.LabelsIncludeAny) + require.Empty(t, teamMeta.LabelsExcludeAny) + require.Len(t, teamMeta.LabelsIncludeAll, 1) + require.Equal(t, lbl.Name, teamMeta.LabelsIncludeAll[0].LabelName) + + // Now re-apply without labels_include_all and confirm they are cleared + err = os.WriteFile(noTeamFilePath, fmt.Appendf(nil, noTeamTemplate, slug, noLabels), 0o644) + require.NoError(t, err) + err = os.WriteFile(teamFile.Name(), fmt.Appendf(nil, teamTemplate, slug, noLabels, teamName), 0o644) + require.NoError(t, err) + + s.assertDryRunOutput(t, fleetctl.RunAppForTest(t, []string{ + "gitops", "--config", fleetctlConfig.Name(), + "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), + "--dry-run", + })) + s.assertRealRunOutput(t, fleetctl.RunAppForTest(t, []string{ + "gitops", "--config", fleetctlConfig.Name(), + "-f", globalFile.Name(), "-f", noTeamFilePath, "-f", teamFile.Name(), + })) + + // Labels should now be empty for no-team + noTeamMeta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, noTeamTitleID, false) + require.NoError(t, err) + require.Empty(t, noTeamMeta.LabelsIncludeAny) + require.Empty(t, noTeamMeta.LabelsExcludeAny) + require.Empty(t, noTeamMeta.LabelsIncludeAll) + + // Labels should now be empty for the team + teamMeta, err = s.DS.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, &team.ID, teamTitleID, false) + require.NoError(t, err) + require.Empty(t, teamMeta.LabelsIncludeAny) + require.Empty(t, teamMeta.LabelsExcludeAny) + require.Empty(t, teamMeta.LabelsIncludeAll) +} diff --git a/cmd/fleetctl/integrationtest/gitops/software_test.go b/cmd/fleetctl/integrationtest/gitops/software_test.go index 00fd71c0ca8..87f53ac5681 100644 --- a/cmd/fleetctl/integrationtest/gitops/software_test.go +++ b/cmd/fleetctl/integrationtest/gitops/software_test.go @@ -54,10 +54,11 @@ func TestGitOpsTeamSoftwareInstallers(t *testing.T) { }, { "testdata/gitops/team_software_installer_invalid_both_include_exclude.yml", - `only one of "labels_exclude_any" or "labels_include_any" can be specified`, + `only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified`, }, {"testdata/gitops/team_software_installer_valid_include.yml", ""}, {"testdata/gitops/team_software_installer_valid_exclude.yml", ""}, + {"testdata/gitops/team_software_installer_valid_include_all.yml", ""}, { "testdata/gitops/team_software_installer_invalid_unknown_label.yml", "Please create the missing labels, or update your settings to not refer to these labels.", @@ -360,10 +361,11 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { }, { "testdata/gitops/no_team_software_installer_invalid_both_include_exclude.yml", - `only one of "labels_exclude_any" or "labels_include_any" can be specified`, + `only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified`, }, {"testdata/gitops/no_team_software_installer_valid_include.yml", ""}, {"testdata/gitops/no_team_software_installer_valid_exclude.yml", ""}, + {"testdata/gitops/no_team_software_installer_valid_include_all.yml", ""}, { "testdata/gitops/no_team_software_installer_invalid_unknown_label.yml", "Please create the missing labels, or update your settings to not refer to these labels.", @@ -505,6 +507,10 @@ func TestGitOpsTeamVPPApps(t *testing.T) { "testdata/gitops/team_vpp_valid_app_labels_include_any.yml", "", time.Now().Add(24 * time.Hour), map[string]uint{"label 1": 1, "label 2": 2}, }, + { + "testdata/gitops/team_vpp_valid_app_labels_include_all.yml", "", time.Now().Add(24 * time.Hour), + map[string]uint{"label 1": 1, "label 2": 2}, + }, { "testdata/gitops/team_vpp_invalid_app_labels_exclude_any.yml", "Please create the missing labels, or update your settings to not refer to these labels.", time.Now().Add(24 * time.Hour), @@ -517,7 +523,7 @@ func TestGitOpsTeamVPPApps(t *testing.T) { }, { "testdata/gitops/team_vpp_invalid_app_labels_both.yml", - `only one of "labels_exclude_any" or "labels_include_any" can be specified for app store app`, time.Now().Add(24 * time.Hour), + `only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified for app store app`, time.Now().Add(24 * time.Hour), map[string]uint{}, }, } diff --git a/ee/server/service/in_house_apps.go b/ee/server/service/in_house_apps.go index 610b8ff0214..d6a7f90c6cf 100644 --- a/ee/server/service/in_house_apps.go +++ b/ee/server/service/in_house_apps.go @@ -35,7 +35,7 @@ func (svc *Service) updateInHouseAppInstaller(ctx context.Context, payload *flee payload.InstallerID = existingInstaller.InstallerID - _, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + _, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating software labels for update") } @@ -127,13 +127,14 @@ func (svc *Service) updateInHouseAppInstaller(ctx context.Context, payload *flee // now that the payload has been updated with any patches, we can set the // final fields of the activity - actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels( - existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels( + existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny, existingInstaller.LabelsIncludeAll) if payload.ValidatedLabels != nil { - actLabelsIncl, actLabelsExcl = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) } - activity.LabelsIncludeAny = actLabelsIncl - activity.LabelsExcludeAny = actLabelsExcl + activity.LabelsIncludeAny = actLabelsInclAny + activity.LabelsExcludeAny = actLabelsExclAny + activity.LabelsIncludeAll = actLabelsInclAll if err := svc.NewActivity(ctx, vc.User, activity); err != nil { return nil, ctxerr.Wrap(ctx, err, "creating activity for edited in house app") } diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index f879cd910cd..6668b965e0b 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -26,7 +26,7 @@ func (svc *Service) AddFleetMaintainedApp( appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, automaticInstall bool, - labelsIncludeAny, labelsExcludeAny []string, + labelsIncludeAny, labelsExcludeAny, labelsIncludeAll []string, ) (titleID uint, err error) { if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil { return 0, err @@ -38,7 +38,7 @@ func (svc *Service) AddFleetMaintainedApp( } // validate labels before we do anything else - validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, labelsIncludeAny, labelsExcludeAny) + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, labelsIncludeAny, labelsExcludeAny, labelsIncludeAll) if err != nil { return 0, ctxerr.Wrap(ctx, err, "validating software labels") } @@ -205,7 +205,7 @@ func (svc *Service) AddFleetMaintainedApp( teamName = &t.Name } - actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{ SoftwareTitle: payload.Title, SoftwarePackage: payload.Filename, @@ -213,8 +213,9 @@ func (svc *Service) AddFleetMaintainedApp( TeamID: payload.TeamID, SelfService: payload.SelfService, SoftwareTitleID: titleID, - LabelsIncludeAny: actLabelsIncl, - LabelsExcludeAny: actLabelsExcl, + LabelsIncludeAny: actLabelsInclAny, + LabelsExcludeAny: actLabelsExclAny, + LabelsIncludeAll: actLabelsInclAll, }); err != nil { return 0, ctxerr.Wrap(ctx, err, "creating activity for added software") } diff --git a/ee/server/service/maintained_apps_test.go b/ee/server/service/maintained_apps_test.go index 71f4e9b02cf..e05625b58db 100644 --- a/ee/server/service/maintained_apps_test.go +++ b/ee/server/service/maintained_apps_test.go @@ -337,7 +337,7 @@ func TestAddFleetMaintainedApp(t *testing.T) { authCtx := authz_ctx.AuthorizationContext{} ctx := authz_ctx.NewContext(context.Background(), &authCtx) ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) - _, err = svc.AddFleetMaintainedApp(ctx, nil, 1, "", "", "", "", false, false, nil, nil) + _, err = svc.AddFleetMaintainedApp(ctx, nil, 1, "", "", "", "", false, false, nil, nil, nil) require.ErrorContains(t, err, "forced error to short-circuit storage and activity creation") require.True(t, ds.MatchOrCreateSoftwareInstallerFuncInvoked) @@ -417,7 +417,7 @@ func TestExtractMaintainedAppVersionWhenLatest(t *testing.T) { authCtx := authz_ctx.AuthorizationContext{} ctx := authz_ctx.NewContext(context.Background(), &authCtx) ctx = viewer.NewContext(ctx, viewer.Viewer{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) - _, err = svc.AddFleetMaintainedApp(ctx, nil, 1, "", "", "", "", false, false, nil, nil) + _, err = svc.AddFleetMaintainedApp(ctx, nil, 1, "", "", "", "", false, false, nil, nil, nil) require.ErrorContains(t, err, "forced error to short-circuit storage and activity creation") require.True(t, ds.MatchOrCreateSoftwareInstallerFuncInvoked) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 8a175560698..377a0301038 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -54,7 +54,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. } // validate labels before we do anything else - validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.TeamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, payload.TeamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating software labels") } @@ -148,7 +148,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. teamName = &t.Name } - actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{ SoftwareTitle: payload.Title, SoftwarePackage: payload.Filename, @@ -156,8 +156,9 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. TeamID: payload.TeamID, SelfService: payload.SelfService, SoftwareTitleID: titleID, - LabelsIncludeAny: actLabelsIncl, - LabelsExcludeAny: actLabelsExcl, + LabelsIncludeAny: actLabelsInclAny, + LabelsExcludeAny: actLabelsExclAny, + LabelsIncludeAll: actLabelsInclAll, }); err != nil { return nil, ctxerr.Wrap(ctx, err, "creating activity for added software") } @@ -195,24 +196,35 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return addedInstaller, nil } -func ValidateSoftwareLabels(ctx context.Context, svc fleet.Service, teamID *uint, labelsIncludeAny, labelsExcludeAny []string) (*fleet.LabelIdentsWithScope, error) { +func ValidateSoftwareLabels(ctx context.Context, svc fleet.Service, teamID *uint, labelsIncludeAny, labelsExcludeAny, labelsIncludeAll []string) (*fleet.LabelIdentsWithScope, error) { if authctx, ok := authz_ctx.FromContext(ctx); !ok { return nil, fleet.NewAuthRequiredError("validate software labels: missing authorization context") } else if !authctx.Checked() { return nil, fleet.NewAuthRequiredError("validate software labels: method requires previous authorization") } + var count int + for _, set := range [][]string{labelsIncludeAny, labelsExcludeAny, labelsIncludeAll} { + if len(set) > 0 { + count++ + } + } + if count > 1 { + return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included.`} + } + var names []string var scope fleet.LabelScope switch { - case len(labelsIncludeAny) > 0 && len(labelsExcludeAny) > 0: - return nil, &fleet.BadRequestError{Message: `Only one of "labels_include_any" or "labels_exclude_any" can be included.`} case len(labelsIncludeAny) > 0: names = labelsIncludeAny scope = fleet.LabelScopeIncludeAny case len(labelsExcludeAny) > 0: names = labelsExcludeAny scope = fleet.LabelScopeExcludeAny + case len(labelsIncludeAll) > 0: + names = labelsIncludeAll + scope = fleet.LabelScopeIncludeAll } if len(names) == 0 { @@ -404,7 +416,7 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. dirty["SelfService"] = true } - shouldUpdateLabels, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + shouldUpdateLabels, validatedLabels, err := ValidateSoftwareLabelsForUpdate(ctx, svc, existingInstaller, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating software labels for update") } @@ -664,13 +676,14 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. // now that the payload has been updated with any patches, we can set the // final fields of the activity - actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels( - existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels( + existingInstaller.LabelsIncludeAny, existingInstaller.LabelsExcludeAny, existingInstaller.LabelsIncludeAll) if payload.ValidatedLabels != nil { - actLabelsIncl, actLabelsExcl = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll = activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels) } - activity.LabelsIncludeAny = actLabelsIncl - activity.LabelsExcludeAny = actLabelsExcl + activity.LabelsIncludeAny = actLabelsInclAny + activity.LabelsExcludeAny = actLabelsExclAny + activity.LabelsIncludeAll = actLabelsInclAll if payload.SelfService != nil { activity.SelfService = *payload.SelfService } @@ -712,7 +725,7 @@ func (svc *Service) validateEmbeddedSecretsOnScript(ctx context.Context, scriptN return argErr } -func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) { +func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, existingInstaller *fleet.SoftwareInstaller, includeAny, excludeAny, includeAll []string) (shouldUpdate bool, validatedLabels *fleet.LabelIdentsWithScope, err error) { if authctx, ok := authz_ctx.FromContext(ctx); !ok { return false, nil, fleet.NewAuthRequiredError("batch validate labels: missing authorization context") } else if !authctx.Checked() { @@ -723,16 +736,12 @@ func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, exi return false, nil, errors.New("existing installer must be provided") } - if len(existingInstaller.LabelsIncludeAny) > 0 && len(existingInstaller.LabelsExcludeAny) > 0 { - return false, nil, errors.New("existing installer must have only one label scope") - } - - if includeAny == nil && excludeAny == nil { + if includeAny == nil && excludeAny == nil && includeAll == nil { // nothing to do return false, nil, nil } - incoming, err := ValidateSoftwareLabels(ctx, svc, existingInstaller.TeamID, includeAny, excludeAny) + incoming, err := ValidateSoftwareLabels(ctx, svc, existingInstaller.TeamID, includeAny, excludeAny, includeAll) if err != nil { return false, nil, err } @@ -746,6 +755,9 @@ func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, exi case len(existingInstaller.LabelsExcludeAny) > 0: prevScope = fleet.LabelScopeExcludeAny prevLabels = existingInstaller.LabelsExcludeAny + case len(existingInstaller.LabelsIncludeAll) > 0: + prevScope = fleet.LabelScopeIncludeAll + prevLabels = existingInstaller.LabelsIncludeAll } prevByName := make(map[string]fleet.LabelIdent, len(prevLabels)) @@ -855,7 +867,7 @@ func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet. teamName = &t.Name } - actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny, meta.LabelsIncludeAll) if err := svc.NewActivity(ctx, vc.User, fleet.ActivityDeletedAppStoreApp{ AppStoreID: meta.AdamID, @@ -863,8 +875,9 @@ func (svc *Service) deleteVPPApp(ctx context.Context, teamID *uint, meta *fleet. TeamName: teamName, TeamID: teamID, Platform: meta.Platform, - LabelsIncludeAny: actLabelsIncl, - LabelsExcludeAny: actLabelsExcl, + LabelsIncludeAny: actLabelsInclAny, + LabelsExcludeAny: actLabelsExclAny, + LabelsIncludeAll: actLabelsInclAll, SoftwareIconURL: meta.IconURL, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for deleted VPP app") @@ -928,15 +941,16 @@ func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.Sof teamName = &t.Name } - actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromSoftwareScopeLabels(meta.LabelsIncludeAny, meta.LabelsExcludeAny, meta.LabelsIncludeAll) if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{ SoftwareTitle: meta.SoftwareTitle, SoftwarePackage: meta.Name, TeamName: teamName, TeamID: meta.TeamID, SelfService: meta.SelfService, - LabelsIncludeAny: actLabelsIncl, - LabelsExcludeAny: actLabelsExcl, + LabelsIncludeAny: actLabelsInclAny, + LabelsExcludeAny: actLabelsExclAny, + LabelsIncludeAll: actLabelsInclAll, SoftwareIconURL: meta.IconUrl, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for deleted software") @@ -2030,7 +2044,7 @@ func (svc *Service) BatchSetSoftwareInstallers( } } if !dryRun { - validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll) if err != nil { return "", err } @@ -2288,6 +2302,7 @@ func (svc *Service) softwareBatchUpload( InstallDuringSetup: p.InstallDuringSetup, LabelsIncludeAny: p.LabelsIncludeAny, LabelsExcludeAny: p.LabelsExcludeAny, + LabelsIncludeAll: p.LabelsIncludeAll, ValidatedLabels: p.ValidatedLabels, Categories: p.Categories, DisplayName: p.DisplayName, @@ -3074,12 +3089,11 @@ func UninstallSoftwareMigration( return nil } -func activitySoftwareLabelsFromValidatedLabels(validatedLabels *fleet.LabelIdentsWithScope) (include, exclude []fleet.ActivitySoftwareLabel) { +func activitySoftwareLabelsFromValidatedLabels(validatedLabels *fleet.LabelIdentsWithScope) (includeAny, excludeAny, includeAll []fleet.ActivitySoftwareLabel) { if validatedLabels == nil || len(validatedLabels.ByName) == 0 { - return nil, nil + return nil, nil, nil } - excludeAny := validatedLabels.LabelScope == fleet.LabelScopeExcludeAny labels := make([]fleet.ActivitySoftwareLabel, 0, len(validatedLabels.ByName)) for _, lbl := range validatedLabels.ByName { labels = append(labels, fleet.ActivitySoftwareLabel{ @@ -3087,26 +3101,35 @@ func activitySoftwareLabelsFromValidatedLabels(validatedLabels *fleet.LabelIdent Name: lbl.LabelName, }) } - if excludeAny { - exclude = labels - } else { - include = labels + switch validatedLabels.LabelScope { + case fleet.LabelScopeIncludeAny: + includeAny = labels + case fleet.LabelScopeExcludeAny: + excludeAny = labels + case fleet.LabelScopeIncludeAll: + includeAll = labels } - return include, exclude + return includeAny, excludeAny, includeAll } -func activitySoftwareLabelsFromSoftwareScopeLabels(includeScopeLabels, excludeScopeLabels []fleet.SoftwareScopeLabel) (include, exclude []fleet.ActivitySoftwareLabel) { - for _, label := range includeScopeLabels { - include = append(include, fleet.ActivitySoftwareLabel{ +func activitySoftwareLabelsFromSoftwareScopeLabels(includeAnyScopeLabels, excludeAnyScopeLabels, includeAllScopeLabels []fleet.SoftwareScopeLabel) (includeAny, excludeAny, includeAll []fleet.ActivitySoftwareLabel) { + for _, label := range includeAnyScopeLabels { + includeAny = append(includeAny, fleet.ActivitySoftwareLabel{ + ID: label.LabelID, + Name: label.LabelName, + }) + } + for _, label := range excludeAnyScopeLabels { + excludeAny = append(excludeAny, fleet.ActivitySoftwareLabel{ ID: label.LabelID, Name: label.LabelName, }) } - for _, label := range excludeScopeLabels { - exclude = append(exclude, fleet.ActivitySoftwareLabel{ + for _, label := range includeAllScopeLabels { + includeAll = append(includeAll, fleet.ActivitySoftwareLabel{ ID: label.LabelID, Name: label.LabelName, }) } - return include, exclude + return includeAny, excludeAny, includeAll } diff --git a/ee/server/service/software_title_icons.go b/ee/server/service/software_title_icons.go index ab887499b33..54adb7a5331 100644 --- a/ee/server/service/software_title_icons.go +++ b/ee/server/service/software_title_icons.go @@ -211,6 +211,7 @@ func generateEditActivityForSoftwareTitleIcon(ctx context.Context, svc *Service, SoftwareIconURL: &iconUrl, LabelsIncludeAny: activityDetailsForSoftwareTitleIcon.LabelsIncludeAny, LabelsExcludeAny: activityDetailsForSoftwareTitleIcon.LabelsExcludeAny, + LabelsIncludeAll: activityDetailsForSoftwareTitleIcon.LabelsIncludeAll, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for software title icon") } @@ -228,6 +229,7 @@ func generateEditActivityForSoftwareTitleIcon(ctx context.Context, svc *Service, SoftwareIconURL: &iconUrl, LabelsIncludeAny: activityDetailsForSoftwareTitleIcon.LabelsIncludeAny, LabelsExcludeAny: activityDetailsForSoftwareTitleIcon.LabelsExcludeAny, + LabelsIncludeAll: activityDetailsForSoftwareTitleIcon.LabelsIncludeAll, SoftwareTitleID: activityDetailsForSoftwareTitleIcon.SoftwareTitleID, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for software title icon") @@ -246,6 +248,7 @@ func generateEditActivityForSoftwareTitleIcon(ctx context.Context, svc *Service, SoftwareIconURL: &iconUrl, LabelsIncludeAny: activityDetailsForSoftwareTitleIcon.LabelsIncludeAny, LabelsExcludeAny: activityDetailsForSoftwareTitleIcon.LabelsExcludeAny, + LabelsIncludeAll: activityDetailsForSoftwareTitleIcon.LabelsIncludeAll, SoftwareTitleID: activityDetailsForSoftwareTitleIcon.SoftwareTitleID, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for software title icon") diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index a003ab1f0d4..08ad7e148d4 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -99,6 +99,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, Platform: fleet.MacOSPlatform, LabelsExcludeAny: payload.LabelsExcludeAny, LabelsIncludeAny: payload.LabelsIncludeAny, + LabelsIncludeAll: payload.LabelsIncludeAll, Categories: payload.Categories, DisplayName: payload.DisplayName, AutoUpdateEnabled: payload.AutoUpdateEnabled, @@ -112,6 +113,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, Platform: fleet.IOSPlatform, LabelsExcludeAny: payload.LabelsExcludeAny, LabelsIncludeAny: payload.LabelsIncludeAny, + LabelsIncludeAll: payload.LabelsIncludeAll, Categories: payload.Categories, DisplayName: payload.DisplayName, AutoUpdateEnabled: payload.AutoUpdateEnabled, @@ -125,6 +127,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, Platform: fleet.IPadOSPlatform, LabelsExcludeAny: payload.LabelsExcludeAny, LabelsIncludeAny: payload.LabelsIncludeAny, + LabelsIncludeAll: payload.LabelsIncludeAll, Categories: payload.Categories, DisplayName: payload.DisplayName, AutoUpdateEnabled: payload.AutoUpdateEnabled, @@ -141,6 +144,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, Platform: payload.Platform, LabelsExcludeAny: payload.LabelsExcludeAny, LabelsIncludeAny: payload.LabelsIncludeAny, + LabelsIncludeAll: payload.LabelsIncludeAll, Categories: payload.Categories, DisplayName: payload.DisplayName, Configuration: payload.Configuration, @@ -184,7 +188,7 @@ func (svc *Service) BatchAssociateVPPApps(ctx context.Context, teamName string, } } - validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll) if err != nil { return nil, ctxerr.Wrap(ctx, err, "validating software labels for batch adding vpp app") } @@ -578,7 +582,7 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee fmt.Sprintf("platform must be one of '%s', '%s', '%s', or '%s'", fleet.IOSPlatform, fleet.IPadOSPlatform, fleet.MacOSPlatform, fleet.AndroidPlatform)) } - validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, appID.LabelsIncludeAny, appID.LabelsExcludeAny) + validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, appID.LabelsIncludeAny, appID.LabelsExcludeAny, appID.LabelsIncludeAll) if err != nil { return 0, ctxerr.Wrap(ctx, err, "validating software labels for adding vpp app") } @@ -757,7 +761,7 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee } } - actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(addedApp.ValidatedLabels) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromValidatedLabels(addedApp.ValidatedLabels) act := fleet.ActivityAddedAppStoreApp{ AppStoreID: app.AdamID, @@ -767,8 +771,9 @@ func (svc *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appID flee SoftwareTitleId: addedApp.TitleID, TeamID: teamID, SelfService: app.SelfService, - LabelsIncludeAny: actLabelsIncl, - LabelsExcludeAny: actLabelsExcl, + LabelsIncludeAny: actLabelsInclAny, + LabelsExcludeAny: actLabelsExclAny, + LabelsIncludeAll: actLabelsInclAll, Configuration: app.Configuration, } @@ -912,9 +917,9 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID } var validatedLabels *fleet.LabelIdentsWithScope - if payload.LabelsExcludeAny != nil || payload.LabelsIncludeAny != nil { + if payload.LabelsExcludeAny != nil || payload.LabelsIncludeAny != nil || payload.LabelsIncludeAll != nil { var err error - validatedLabels, err = ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny) + validatedLabels, err = ValidateSoftwareLabels(ctx, svc, teamID, payload.LabelsIncludeAny, payload.LabelsExcludeAny, payload.LabelsIncludeAll) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "UpdateAppStoreApp: validating software labels") } @@ -995,6 +1000,13 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID for _, l := range meta.LabelsIncludeAny { existingLabels.ByName[l.LabelName] = fleet.LabelIdent{LabelName: l.LabelName, LabelID: l.LabelID} } + + case len(meta.LabelsIncludeAll) > 0: + existingLabels.LabelScope = fleet.LabelScopeIncludeAll + existingLabels.ByName = make(map[string]fleet.LabelIdent, len(meta.LabelsIncludeAll)) + for _, l := range meta.LabelsIncludeAll { + existingLabels.ByName[l.LabelName] = fleet.LabelIdent{LabelName: l.LabelName, LabelID: l.LabelID} + } } var labelsChanged bool if validatedLabels != nil { @@ -1066,7 +1078,7 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID } } - actLabelsIncl, actLabelsExcl := activitySoftwareLabelsFromValidatedLabels(validatedLabels) + actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromValidatedLabels(validatedLabels) displayNameVal := ptr.ValOrZero(payload.DisplayName) @@ -1078,8 +1090,9 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID SoftwareTitle: meta.Name, AppStoreID: meta.AdamID, Platform: meta.Platform, - LabelsIncludeAny: actLabelsIncl, - LabelsExcludeAny: actLabelsExcl, + LabelsIncludeAny: actLabelsInclAny, + LabelsExcludeAny: actLabelsExclAny, + LabelsIncludeAll: actLabelsInclAll, SoftwareIconURL: meta.IconURL, SoftwareDisplayName: displayNameVal, Configuration: appToWrite.Configuration, diff --git a/ee/server/service/vpp_test.go b/ee/server/service/vpp_test.go index 74ef2cfa0c3..b81184ccdab 100644 --- a/ee/server/service/vpp_test.go +++ b/ee/server/service/vpp_test.go @@ -32,6 +32,7 @@ func TestBatchAssociateVPPApps(t *testing.T) { AppStoreID: "my-fake-app", LabelsExcludeAny: []string{}, LabelsIncludeAny: []string{}, + LabelsIncludeAll: []string{}, Categories: []string{}, Platform: fleet.MacOSPlatform, }, @@ -44,6 +45,7 @@ func TestBatchAssociateVPPApps(t *testing.T) { AppStoreID: "my-fake-app", LabelsExcludeAny: []string{}, LabelsIncludeAny: []string{}, + LabelsIncludeAll: []string{}, Categories: []string{}, Platform: fleet.MacOSPlatform, }, @@ -70,6 +72,7 @@ func TestBatchAssociateVPPApps(t *testing.T) { AppStoreID: pkg, LabelsExcludeAny: []string{}, LabelsIncludeAny: []string{}, + LabelsIncludeAll: []string{}, Categories: []string{}, Platform: fleet.AndroidPlatform, }, @@ -82,6 +85,7 @@ func TestBatchAssociateVPPApps(t *testing.T) { AppStoreID: pkg, LabelsExcludeAny: []string{}, LabelsIncludeAny: []string{}, + LabelsIncludeAll: []string{}, Categories: []string{}, Platform: fleet.AndroidPlatform, }, diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 0c2d2dd97d1..b23ba1d4b89 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -251,6 +251,7 @@ func (spec SoftwarePackage) HydrateToPackageLevel(packageLevel fleet.SoftwarePac packageLevel.Categories = spec.Categories packageLevel.LabelsIncludeAny = spec.LabelsIncludeAny packageLevel.LabelsExcludeAny = spec.LabelsExcludeAny + packageLevel.LabelsIncludeAll = spec.LabelsIncludeAll packageLevel.InstallDuringSetup = spec.InstallDuringSetup packageLevel.SelfService = spec.SelfService @@ -1605,8 +1606,14 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin continue } - if len(item.LabelsExcludeAny) > 0 && len(item.LabelsIncludeAny) > 0 { - multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_exclude_any" or "labels_include_any" can be specified for app store app %q`, item.AppStoreID)) + var count int + for _, set := range [][]string{item.LabelsExcludeAny, item.LabelsIncludeAny, item.LabelsIncludeAll} { + if len(set) > 0 { + count++ + } + } + if count > 1 { + multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified for app store app %q`, item.AppStoreID)) continue } @@ -1626,8 +1633,14 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin continue } - if len(maintainedAppSpec.LabelsExcludeAny) > 0 && len(maintainedAppSpec.LabelsIncludeAny) > 0 { - multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_exclude_any" or "labels_include_any" can be specified for fleet maintained app %q`, maintainedAppSpec.Slug)) + var count int + for _, set := range [][]string{maintainedAppSpec.LabelsExcludeAny, maintainedAppSpec.LabelsIncludeAny, maintainedAppSpec.LabelsIncludeAll} { + if len(set) > 0 { + count++ + } + } + if count > 1 { + multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified for fleet maintained app %q`, maintainedAppSpec.Slug)) continue } @@ -1752,10 +1765,18 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin continue } } - if len(softwarePackageSpec.LabelsExcludeAny) > 0 && len(softwarePackageSpec.LabelsIncludeAny) > 0 { - multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_exclude_any" or "labels_include_any" can be specified for software URL %q`, softwarePackageSpec.URL)) + + var count int + for _, set := range [][]string{softwarePackageSpec.LabelsExcludeAny, softwarePackageSpec.LabelsIncludeAny, softwarePackageSpec.LabelsIncludeAll} { + if len(set) > 0 { + count++ + } + } + if count > 1 { + multiError = multierror.Append(multiError, fmt.Errorf(`only one of "labels_include_all", "labels_exclude_any" or "labels_include_any" can be specified for software URL %q`, softwarePackageSpec.URL)) continue } + if softwarePackageSpec.SHA256 != "" && !validSHA256Value.MatchString(softwarePackageSpec.SHA256) { multiError = multierror.Append(multiError, fmt.Errorf("hash_sha256 value %q must be a valid lower-case hex-encoded (64-character) SHA-256 hash value", softwarePackageSpec.SHA256)) continue diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index 1fbe81b735c..72778685df0 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -211,10 +211,12 @@ func TestValidGitOpsYaml(t *testing.T) { assert.Contains(t, pkg.LabelsIncludeAny, "a") assert.Contains(t, pkg.Categories, "Communication") assert.Empty(t, pkg.LabelsExcludeAny) + assert.Empty(t, pkg.LabelsIncludeAll) } else { assert.Empty(t, pkg.UninstallScript.Path) assert.Contains(t, pkg.LabelsExcludeAny, "a") assert.Empty(t, pkg.LabelsIncludeAny) + assert.Empty(t, pkg.LabelsIncludeAll) } } require.Len(t, gitops.Software.FleetMaintainedApps, 2) @@ -2417,7 +2419,7 @@ agent_options: org_settings: server_settings: org_info: - secrets: + secrets: controls: apple_settings: configuration_profiles: @@ -2445,7 +2447,7 @@ agent_options: org_settings: server_settings: org_info: - secrets: + secrets: controls: apple_settings: configuration_profiles: @@ -2474,7 +2476,7 @@ agent_options: org_settings: server_settings: org_info: - secrets: + secrets: controls: windows_settings: configuration_profiles: @@ -2503,7 +2505,7 @@ agent_options: org_settings: server_settings: org_info: - secrets: + secrets: controls: android_settings: configuration_profiles: @@ -2532,10 +2534,10 @@ agent_options: org_settings: server_settings: org_info: - secrets: + secrets: controls: setup_experience: - macos_setup: + macos_setup: ` yamlPath := filepath.Join(dir, "gitops.yml") require.NoError(t, os.WriteFile(yamlPath, []byte(config), 0o644)) diff --git a/server/datastore/mysql/in_house_apps.go b/server/datastore/mysql/in_house_apps.go index 77e178fc1bd..4342f6e09c5 100644 --- a/server/datastore/mysql/in_house_apps.go +++ b/server/datastore/mysql/in_house_apps.go @@ -211,20 +211,32 @@ WHERE if err != nil { return nil, ctxerr.Wrap(ctx, err, "get in house app labels") } - var exclAny, inclAny []fleet.SoftwareScopeLabel + var exclAny, inclAny, inclAll []fleet.SoftwareScopeLabel for _, l := range labels { - if l.Exclude { + switch { + case l.Exclude && !l.RequireAll: exclAny = append(exclAny, l) - } else { + case !l.Exclude && l.RequireAll: + inclAll = append(inclAll, l) + case !l.Exclude && !l.RequireAll: inclAny = append(inclAny, l) + default: + ds.logger.WarnContext(ctx, "in house app has an unsupported label scope", "installer_id", dest.InstallerID, "invalid_label", fmt.Sprintf("%#v", l)) } } - if len(inclAny) > 0 && len(exclAny) > 0 { - ds.logger.WarnContext(ctx, "in house app has both include and exclude labels", "installer_id", dest.InstallerID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny)) + var count int + for _, set := range [][]fleet.SoftwareScopeLabel{exclAny, inclAny, inclAll} { + if len(set) > 0 { + count++ + } + } + if count > 1 { + ds.logger.WarnContext(ctx, "in house app has more than one scope of labels", "installer_id", dest.InstallerID, "include_any", fmt.Sprintf("%v", inclAny), "exclude_any", fmt.Sprintf("%v", exclAny), "include_all", fmt.Sprintf("%v", inclAll)) } dest.LabelsExcludeAny = exclAny dest.LabelsIncludeAny = inclAny + dest.LabelsIncludeAll = inclAll categoryMap, err := ds.GetCategoriesForSoftwareTitles(ctx, []uint{titleID}, teamID) if err != nil { @@ -961,18 +973,21 @@ INSERT INTO in_house_app_labels ( in_house_app_id, label_id, - exclude + exclude, + require_all ) VALUES %s ON DUPLICATE KEY UPDATE - exclude = VALUES(exclude) + exclude = VALUES(exclude), + require_all = VALUES(require_all) ` const loadExistingInHouseLabels = ` SELECT label_id, - exclude + exclude, + require_all FROM in_house_app_labels WHERE @@ -1322,13 +1337,15 @@ WHERE } excludeLabels := installer.ValidatedLabels.LabelScope == fleet.LabelScopeExcludeAny + requireAllLabels := installer.ValidatedLabels.LabelScope == fleet.LabelScopeIncludeAll if len(existing) > 0 && !existing[0].IsMetadataModified { // load the remaining labels for that installer, so that we can detect // if any label changed (if the counts differ, then labels did change, - // otherwise if the exclude bool changed, the target did change). + // otherwise if the exclude/require all bool changed, the target did change). var existingLabels []struct { - LabelID uint `db:"label_id"` - Exclude bool `db:"exclude"` + LabelID uint `db:"label_id"` + Exclude bool `db:"exclude"` + RequireAll bool `db:"require_all"` } if err := sqlx.SelectContext(ctx, tx, &existingLabels, loadExistingInHouseLabels, installerID); err != nil { return ctxerr.Wrapf(ctx, err, "load existing labels for in-house with name %q", installer.Filename) @@ -1337,8 +1354,8 @@ WHERE if len(existingLabels) != len(labelIDs) { existing[0].IsMetadataModified = true } - if len(existingLabels) > 0 && existingLabels[0].Exclude != excludeLabels { - // same labels are provided, but the include <-> exclude changed + if len(existingLabels) > 0 && (existingLabels[0].Exclude != excludeLabels || existingLabels[0].RequireAll != requireAllLabels) { + // same labels are provided, but the include <-> exclude or require all changed existing[0].IsMetadataModified = true } } @@ -1346,9 +1363,9 @@ WHERE // upsert the new labels now that obsolete ones have been deleted var upsertLabelArgs []any for _, lblID := range labelIDs { - upsertLabelArgs = append(upsertLabelArgs, installerID, lblID, excludeLabels) + upsertLabelArgs = append(upsertLabelArgs, installerID, lblID, excludeLabels, requireAllLabels) } - upsertLabelValues := strings.TrimSuffix(strings.Repeat("(?,?,?),", len(installer.ValidatedLabels.ByName)), ",") + upsertLabelValues := strings.TrimSuffix(strings.Repeat("(?,?,?,?),", len(installer.ValidatedLabels.ByName)), ",") _, err = tx.ExecContext(ctx, fmt.Sprintf(upsertInHouseLabels, upsertLabelValues), upsertLabelArgs...) if err != nil { diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 9c74b23ebb0..42b26d7f57b 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -3479,14 +3479,16 @@ func filterSoftwareInstallersByLabel( FROM software_installers INNER JOIN software_installer_labels - ON software_installer_labels.software_installer_id = software_installers.id AND software_installer_labels.exclude = 0 + ON software_installer_labels.software_installer_id = software_installers.id + AND software_installer_labels.exclude = 0 + AND software_installer_labels.require_all = 0 LEFT JOIN label_membership ON label_membership.label_id = software_installer_labels.label_id AND label_membership.host_id = :host_id GROUP BY software_installers.id HAVING - COUNT(*) > 0 AND COUNT(label_membership.label_id) > 0 + count_installer_labels > 0 AND count_host_labels > 0 ), exclude_any AS ( SELECT @@ -3505,7 +3507,9 @@ func filterSoftwareInstallersByLabel( FROM software_installers INNER JOIN software_installer_labels - ON software_installer_labels.software_installer_id = software_installers.id AND software_installer_labels.exclude = 1 + ON software_installer_labels.software_installer_id = software_installers.id + AND software_installer_labels.exclude = 1 + AND software_installer_labels.require_all = 0 INNER JOIN labels ON labels.id = software_installer_labels.label_id LEFT JOIN label_membership @@ -3514,17 +3518,30 @@ func filterSoftwareInstallersByLabel( GROUP BY software_installers.id HAVING - COUNT(*) > 0 - AND COUNT(*) = SUM( - CASE - WHEN labels.created_at IS NOT NULL AND ( - labels.label_membership_type = 1 OR - (labels.label_membership_type = 0 AND :host_label_updated_at >= labels.created_at) - ) THEN 1 - ELSE 0 - END - ) - AND COUNT(label_membership.label_id) = 0 + count_installer_labels > 0 + AND count_installer_labels = count_host_updated_after_labels + AND count_host_labels = 0 + ), + include_all AS ( + SELECT + software_installers.id AS installer_id, + COUNT(*) AS count_installer_labels, + COUNT(label_membership.label_id) AS count_host_labels, + 0 AS count_host_updated_after_labels + FROM + software_installers + INNER JOIN software_installer_labels + ON software_installer_labels.software_installer_id = software_installers.id + AND software_installer_labels.exclude = 0 + AND software_installer_labels.require_all = 1 + LEFT JOIN label_membership + ON label_membership.label_id = software_installer_labels.label_id + AND label_membership.host_id = :host_id + GROUP BY + software_installers.id + HAVING + count_installer_labels > 0 + AND count_host_labels = count_installer_labels ) SELECT software_installers.id AS id, @@ -3537,6 +3554,8 @@ func filterSoftwareInstallersByLabel( ON include_any.installer_id = software_installers.id LEFT JOIN exclude_any ON exclude_any.installer_id = software_installers.id + LEFT JOIN include_all + ON include_all.installer_id = software_installers.id WHERE software_installers.global_or_team_id = :global_or_team_id AND software_installers.id IN (:software_installer_ids) @@ -3544,6 +3563,7 @@ func filterSoftwareInstallersByLabel( no_labels.installer_id IS NOT NULL OR include_any.installer_id IS NOT NULL OR exclude_any.installer_id IS NOT NULL + OR include_all.installer_id IS NOT NULL ) ` labelSqlFilter, args, err := sqlx.Named(labelSqlFilter, map[string]any{ @@ -3633,7 +3653,9 @@ func filterVPPAppsByLabel( FROM vpp_apps_teams INNER JOIN vpp_app_team_labels - ON vpp_app_team_labels.vpp_app_team_id = vpp_apps_teams.id AND vpp_app_team_labels.exclude = 0 + ON vpp_app_team_labels.vpp_app_team_id = vpp_apps_teams.id + AND vpp_app_team_labels.exclude = 0 + AND vpp_app_team_labels.require_all = 0 LEFT JOIN label_membership ON label_membership.label_id = vpp_app_team_labels.label_id AND label_membership.host_id = :host_id @@ -3657,7 +3679,9 @@ func filterVPPAppsByLabel( FROM vpp_apps_teams INNER JOIN vpp_app_team_labels - ON vpp_app_team_labels.vpp_app_team_id = vpp_apps_teams.id AND vpp_app_team_labels.exclude = 1 + ON vpp_app_team_labels.vpp_app_team_id = vpp_apps_teams.id + AND vpp_app_team_labels.exclude = 1 + AND vpp_app_team_labels.require_all = 0 INNER JOIN labels ON labels.id = vpp_app_team_labels.label_id LEFT OUTER JOIN label_membership @@ -3668,6 +3692,26 @@ func filterVPPAppsByLabel( count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 + ), + include_all AS ( + SELECT + vpp_apps_teams.id AS team_id, + COUNT(vpp_app_team_labels.label_id) AS count_installer_labels, + COUNT(label_membership.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + vpp_apps_teams + INNER JOIN vpp_app_team_labels + ON vpp_app_team_labels.vpp_app_team_id = vpp_apps_teams.id + AND vpp_app_team_labels.exclude = 0 + AND vpp_app_team_labels.require_all = 1 + LEFT JOIN label_membership + ON label_membership.label_id = vpp_app_team_labels.label_id + AND label_membership.host_id = :host_id + GROUP BY + vpp_apps_teams.id + HAVING + count_installer_labels > 0 AND count_host_labels = count_installer_labels ) SELECT vpp_apps.adam_id AS adam_id, @@ -3675,19 +3719,24 @@ func filterVPPAppsByLabel( FROM vpp_apps INNER JOIN - vpp_apps_teams ON vpp_apps.adam_id = vpp_apps_teams.adam_id AND vpp_apps.platform = vpp_apps_teams.platform AND vpp_apps_teams.global_or_team_id = :global_or_team_id + vpp_apps_teams ON vpp_apps.adam_id = vpp_apps_teams.adam_id + AND vpp_apps.platform = vpp_apps_teams.platform + AND vpp_apps_teams.global_or_team_id = :global_or_team_id LEFT JOIN no_labels ON no_labels.team_id = vpp_apps_teams.id LEFT JOIN include_any ON include_any.team_id = vpp_apps_teams.id LEFT JOIN exclude_any ON exclude_any.team_id = vpp_apps_teams.id + LEFT JOIN include_all + ON include_all.team_id = vpp_apps_teams.id WHERE vpp_apps.adam_id IN (:vpp_app_adam_ids) AND ( no_labels.team_id IS NOT NULL OR include_any.team_id IS NOT NULL OR exclude_any.team_id IS NOT NULL + OR include_all.team_id IS NOT NULL ) ` @@ -3786,8 +3835,10 @@ func filterInHouseAppsByLabel( 0 as count_host_updated_after_labels FROM in_house_apps iha - INNER JOIN in_house_app_labels ihl ON - ihl.in_house_app_id = iha.id AND ihl.exclude = 0 + INNER JOIN in_house_app_labels ihl + ON ihl.in_house_app_id = iha.id + AND ihl.exclude = 0 + AND ihl.require_all = 0 LEFT JOIN label_membership lm ON lm.label_id = ihl.label_id AND lm.host_id = :host_id GROUP BY @@ -3809,8 +3860,10 @@ func filterInHouseAppsByLabel( ) AS count_host_updated_after_labels FROM in_house_apps iha - INNER JOIN in_house_app_labels ihl ON - ihl.in_house_app_id = iha.id AND ihl.exclude = 1 + INNER JOIN in_house_app_labels ihl + ON ihl.in_house_app_id = iha.id + AND ihl.exclude = 1 + AND ihl.require_all = 0 INNER JOIN labels lbl ON lbl.id = ihl.label_id LEFT OUTER JOIN label_membership lm ON @@ -3821,6 +3874,25 @@ func filterInHouseAppsByLabel( count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 + ), + include_all AS ( + SELECT + iha.id AS in_house_app_id, + COUNT(ihl.label_id) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + in_house_apps iha + INNER JOIN in_house_app_labels ihl + ON ihl.in_house_app_id = iha.id + AND ihl.exclude = 0 + AND ihl.require_all = 1 + LEFT JOIN label_membership lm ON + lm.label_id = ihl.label_id AND lm.host_id = :host_id + GROUP BY + iha.id + HAVING + count_installer_labels > 0 AND count_host_labels = count_installer_labels ) SELECT iha.id AS in_house_id, @@ -3833,12 +3905,15 @@ func filterInHouseAppsByLabel( ON include_any.in_house_app_id = iha.id LEFT JOIN exclude_any ON exclude_any.in_house_app_id = iha.id + LEFT JOIN include_all + ON include_all.in_house_app_id = iha.id WHERE iha.global_or_team_id = :global_or_team_id AND iha.id IN (:in_house_ids) AND ( no_labels.in_house_app_id IS NOT NULL OR include_any.in_house_app_id IS NOT NULL OR - exclude_any.in_house_app_id IS NOT NULL + exclude_any.in_house_app_id IS NOT NULL OR + include_all.in_house_app_id IS NOT NULL ) ` @@ -4720,7 +4795,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt SELECT 1 FROM ( - -- no labels + -- no labels for any type of installer SELECT 0 AS count_installer_labels, 0 AS count_host_labels, 0 as count_host_updated_after_labels WHERE NOT EXISTS (SELECT 1 FROM software_installer_labels sil WHERE sil.software_installer_id = si.id) AND @@ -4741,6 +4816,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt WHERE sil.software_installer_id = si.id AND sil.exclude = 0 + AND sil.require_all = 0 HAVING count_installer_labels > 0 AND count_host_labels > 0 @@ -4766,11 +4842,30 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt WHERE sil.software_installer_id = si.id AND sil.exclude = 1 + AND sil.require_all = 0 HAVING count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 UNION + -- include all for software installers + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + software_installer_labels sil + LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id + AND lm.host_id = :host_id + WHERE + sil.software_installer_id = si.id + AND sil.exclude = 0 + AND sil.require_all = 1 + HAVING + count_installer_labels > 0 AND count_host_labels = count_installer_labels + + UNION + -- include any for VPP apps SELECT COUNT(*) AS count_installer_labels, @@ -4783,6 +4878,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt WHERE vatl.vpp_app_team_id = vat.id AND vatl.exclude = 0 + AND vatl.require_all = 0 HAVING count_installer_labels > 0 AND count_host_labels > 0 @@ -4805,11 +4901,30 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt WHERE vatl.vpp_app_team_id = vat.id AND vatl.exclude = 1 + AND vatl.require_all = 0 HAVING count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 UNION + -- include all for VPP apps + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + vpp_app_team_labels vatl + LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id + AND lm.host_id = :host_id + WHERE + vatl.vpp_app_team_id = vat.id + AND vatl.exclude = 0 + AND vatl.require_all = 1 + HAVING + count_installer_labels > 0 AND count_host_labels = count_installer_labels + + UNION + -- include any for in-house apps SELECT COUNT(*) AS count_installer_labels, @@ -4821,6 +4936,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt WHERE ihl.in_house_app_id = iha.id AND ihl.exclude = 0 + AND ihl.require_all = 0 HAVING count_installer_labels > 0 AND count_host_labels > 0 @@ -4839,10 +4955,28 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt LEFT OUTER JOIN labels lbl ON lbl.id = ihl.label_id LEFT OUTER JOIN label_membership lm ON lm.label_id = ihl.label_id AND lm.host_id = :host_id WHERE - ihl.in_house_app_id = iha.id AND - ihl.exclude = 1 + ihl.in_house_app_id = iha.id + AND ihl.exclude = 1 + AND ihl.require_all = 0 HAVING count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 + + UNION + + -- include all for in-house apps + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + in_house_app_labels ihl + LEFT OUTER JOIN label_membership lm ON lm.label_id = ihl.label_id AND lm.host_id = :host_id + WHERE + ihl.in_house_app_id = iha.id + AND ihl.exclude = 0 + AND ihl.require_all = 1 + HAVING + count_installer_labels > 0 AND count_host_labels = count_installer_labels ) t ) ) diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index f2f329e788d..e656d57ac8e 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -617,23 +617,29 @@ func setOrUpdateSoftwareInstallerLabelsDB(ctx context.Context, tx sqlx.ExtContex // insert new labels if len(labelIds) > 0 { - var exclude bool + var exclude, requireAll bool switch labels.LabelScope { case fleet.LabelScopeIncludeAny: exclude = false + requireAll = false case fleet.LabelScopeExcludeAny: exclude = true + requireAll = false + case fleet.LabelScopeIncludeAll: + exclude = false + requireAll = true default: // this should never happen return ctxerr.New(ctx, "invalid label scope") } - stmt := `INSERT INTO %[1]s_labels (%[1]s_id, label_id, exclude) VALUES %s ON DUPLICATE KEY UPDATE exclude = VALUES(exclude)` + stmt := `INSERT INTO %[1]s_labels (%[1]s_id, label_id, exclude, require_all) VALUES %s + ON DUPLICATE KEY UPDATE exclude = VALUES(exclude), require_all = VALUES(require_all)` var placeholders string var insertArgs []interface{} for _, lid := range labelIds { - placeholders += "(?, ?, ?)," - insertArgs = append(insertArgs, installerID, lid, exclude) + placeholders += "(?, ?, ?, ?)," + insertArgs = append(insertArgs, installerID, lid, exclude, requireAll) } placeholders = strings.TrimSuffix(placeholders, ",") @@ -1036,21 +1042,32 @@ LIMIT 1`, if err != nil { return nil, ctxerr.Wrap(ctx, err, "get software installer labels") } - var exclAny, inclAny []fleet.SoftwareScopeLabel + var exclAny, inclAny, inclAll []fleet.SoftwareScopeLabel for _, l := range labels { - if l.Exclude { + switch { + case l.Exclude && !l.RequireAll: exclAny = append(exclAny, l) - } else { + case !l.Exclude && l.RequireAll: + inclAll = append(inclAll, l) + case !l.Exclude && !l.RequireAll: inclAny = append(inclAny, l) + default: + ds.logger.WarnContext(ctx, "software installer has an unsupported label scope", "installer_id", dest.InstallerID, "invalid_label", fmt.Sprintf("%#v", l)) } } - if len(inclAny) > 0 && len(exclAny) > 0 { - // there's a bug somewhere - ds.logger.WarnContext(ctx, "software installer has both include and exclude labels", "installer_id", dest.InstallerID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny)) + var count int + for _, set := range [][]fleet.SoftwareScopeLabel{exclAny, inclAny, inclAll} { + if len(set) > 0 { + count++ + } + } + if count > 1 { + ds.logger.WarnContext(ctx, "software installer has more than one scope of labels", "installer_id", dest.InstallerID, "include_any", fmt.Sprintf("%v", inclAny), "exclude_any", fmt.Sprintf("%v", exclAny), "include_all", fmt.Sprintf("%v", inclAll)) } dest.LabelsExcludeAny = exclAny dest.LabelsIncludeAny = inclAny + dest.LabelsIncludeAll = inclAll categoryMap, err := ds.GetCategoriesForSoftwareTitles(ctx, []uint{titleID}, teamID) if err != nil { @@ -1093,7 +1110,8 @@ SELECT label_id, exclude, l.name as label_name, - si.title_id + si.title_id, + require_all FROM %[1]s_labels sil JOIN %[1]ss si ON si.id = sil.%[1]s_id @@ -2339,18 +2357,21 @@ INSERT INTO software_installer_labels ( software_installer_id, label_id, - exclude + exclude, + require_all ) VALUES %s ON DUPLICATE KEY UPDATE - exclude = VALUES(exclude) + exclude = VALUES(exclude), + require_all = VALUES(require_all) ` const loadExistingInstallerLabels = ` SELECT label_id, - exclude + exclude, + require_all FROM software_installer_labels WHERE @@ -2893,13 +2914,15 @@ WHERE } excludeLabels := installer.ValidatedLabels.LabelScope == fleet.LabelScopeExcludeAny + requireAllLabels := installer.ValidatedLabels.LabelScope == fleet.LabelScopeIncludeAll if len(existing) > 0 && !existing[0].IsMetadataModified { // load the remaining labels for that installer, so that we can detect // if any label changed (if the counts differ, then labels did change, - // otherwise if the exclude bool changed, the target did change). + // otherwise if the exclude/require all bool changed, the target did change). var existingLabels []struct { - LabelID uint `db:"label_id"` - Exclude bool `db:"exclude"` + LabelID uint `db:"label_id"` + Exclude bool `db:"exclude"` + RequireAll bool `db:"require_all"` } if err := sqlx.SelectContext(ctx, tx, &existingLabels, loadExistingInstallerLabels, installerID); err != nil { return ctxerr.Wrapf(ctx, err, "load existing labels for installer with name %q", installer.Filename) @@ -2908,8 +2931,8 @@ WHERE if len(existingLabels) != len(labelIDs) { existing[0].IsMetadataModified = true } - if len(existingLabels) > 0 && existingLabels[0].Exclude != excludeLabels { - // same labels are provided, but the include <-> exclude changed + if len(existingLabels) > 0 && (existingLabels[0].Exclude != excludeLabels || existingLabels[0].RequireAll != requireAllLabels) { + // same labels are provided, but the include <-> exclude or require all changed existing[0].IsMetadataModified = true } } @@ -2917,9 +2940,9 @@ WHERE // upsert the new labels now that obsolete ones have been deleted var upsertLabelArgs []any for _, lblID := range labelIDs { - upsertLabelArgs = append(upsertLabelArgs, installerID, lblID, excludeLabels) + upsertLabelArgs = append(upsertLabelArgs, installerID, lblID, excludeLabels, requireAllLabels) } - upsertLabelValues := strings.TrimSuffix(strings.Repeat("(?,?,?),", len(installer.ValidatedLabels.ByName)), ",") + upsertLabelValues := strings.TrimSuffix(strings.Repeat("(?,?,?,?),", len(installer.ValidatedLabels.ByName)), ",") _, err = tx.ExecContext(ctx, fmt.Sprintf(upsertInstallerLabels, upsertLabelValues), upsertLabelArgs...) if err != nil { @@ -3203,6 +3226,7 @@ func (ds *Datastore) isSoftwareLabelScoped(ctx context.Context, softwareID, host WHERE sil.%[1]s_id = :software_id AND sil.exclude = 0 + AND sil.require_all = 0 HAVING count_installer_labels > 0 AND count_host_labels > 0 @@ -3232,8 +3256,27 @@ func (ds *Datastore) isSoftwareLabelScoped(ctx context.Context, softwareID, host WHERE sil.%[1]s_id = :software_id AND sil.exclude = 1 + AND sil.require_all = 0 HAVING count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels AND count_host_labels = 0 + + UNION + + -- include all + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 as count_host_updated_after_labels + FROM + %[1]s_labels sil + LEFT OUTER JOIN label_membership lm ON lm.label_id = sil.label_id + AND lm.host_id = :host_id + WHERE + sil.%[1]s_id = :software_id + AND sil.exclude = 0 + AND sil.require_all = 1 + HAVING + count_installer_labels > 0 AND count_host_labels = count_installer_labels ) t ` diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index e6562808c0b..37dc4c97fbd 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -9,7 +9,9 @@ import ( "encoding/hex" "fmt" "log/slog" + "maps" "math/rand" + std_slices "slices" "sort" "strconv" "strings" @@ -7630,7 +7632,8 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { require.False(t, ok) continue } - require.True(t, ok) + names := std_slices.Collect(maps.Keys(expectedInstallers)) + require.Truef(t, ok, "didn't find installer for %s in expectedInstallers (%s)", got.SoftwarePackage.Name, strings.Join(names, ", ")) require.Equal(t, want, got.SoftwarePackage) } } @@ -7971,6 +7974,65 @@ func testListHostSoftwareWithLabelScoping(t *testing.T, ds *Datastore) { software, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) checkSoftware(software, installer2.Filename, installer3.Filename, installer4.Filename) + + t.Run("include_all", func(t *testing.T) { + + hostIncludeAll := test.NewHost(t, ds, "host_include_all", "", "host1key_include_all", "host1uuid_include_all", time.Now(), test.WithPlatform("darwin")) + nanoEnroll(t, ds, hostIncludeAll, false) + + label4, err := ds.NewLabel(ctx, &fleet.Label{Name: "label4" + t.Name()}) + require.NoError(t, err) + + // Scope installer1 to include_all: [label1, label4]. + // hostIncludeAll has neither label yet, so installer1 should be out of scope. + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), installerID1, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAll, + ByName: map[string]fleet.LabelIdent{label1.Name: {LabelName: label1.Name, LabelID: label1.ID}, label4.Name: {LabelName: label4.Name, LabelID: label4.ID}}, + }, softwareTypeInstaller) + require.NoError(t, err) + + // host has no labels yet β€” installer1 is out of scope + scoped, err := ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, hostIncludeAll.ID) + require.NoError(t, err) + require.False(t, scoped) + + software, _, err = ds.ListHostSoftware(ctx, hostIncludeAll, opts) + require.NoError(t, err) + // installer1 should be absent (out of scope), installer4 absent (no labels on host) + checkSoftware(software, installer1.Filename, installer4.Filename) + + // add only label1: host still missing label4, so still out of scope + require.NoError(t, ds.AddLabelsToHost(ctx, hostIncludeAll.ID, []uint{label1.ID})) + hostIncludeAll.LabelUpdatedAt = time.Now() + err = ds.UpdateHost(ctx, hostIncludeAll) + require.NoError(t, err) + time.Sleep(time.Second) + + scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, hostIncludeAll.ID) + require.NoError(t, err) + require.False(t, scoped) + + software, _, err = ds.ListHostSoftware(ctx, hostIncludeAll, opts) + require.NoError(t, err) + checkSoftware(software, installer1.Filename, installer4.Filename) + + // add label4 β€” host now has both required labels, so installer1 is in scope + require.NoError(t, ds.AddLabelsToHost(ctx, hostIncludeAll.ID, []uint{label4.ID})) + hostIncludeAll.LabelUpdatedAt = time.Now() + err = ds.UpdateHost(ctx, hostIncludeAll) + require.NoError(t, err) + time.Sleep(time.Second) + + scoped, err = ds.IsSoftwareInstallerLabelScoped(ctx, installerID1, hostIncludeAll.ID) + require.NoError(t, err) + require.True(t, scoped) + + software, _, err = ds.ListHostSoftware(ctx, hostIncludeAll, opts) + require.NoError(t, err) + // installer1 is now in scope; installer4 still absent (no labels on host match it) + checkSoftware(software, installer4.Filename) + }) + } func testListHostSoftwareVulnerableAndVPP(t *testing.T, ds *Datastore) { @@ -9094,6 +9156,63 @@ func testListHostSoftwareWithLabelScopingVPP(t *testing.T, ds *Datastore) { scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) require.NoError(t, err) require.True(t, scoped) + + // --- include_all tests for VPP --- + // Create two fresh labels for the include_all scope tests. + label5, err := ds.NewLabel(ctx, &fleet.Label{Name: "label5" + t.Name()}) + require.NoError(t, err) + label6, err := ds.NewLabel(ctx, &fleet.Label{Name: "label6" + t.Name()}) + require.NoError(t, err) + + // Scope the VPP app to include_all: [label5, label6]. + // host currently has label1 but not label5 or label6. + err = setOrUpdateSoftwareInstallerLabelsDB(ctx, ds.writer(ctx), vppAppTeamID, fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAll, + ByName: map[string]fleet.LabelIdent{ + label5.Name: {LabelName: label5.Name, LabelID: label5.ID}, + label6.Name: {LabelName: label6.Name, LabelID: label6.ID}, + }, + }, softwareTypeVPP) + require.NoError(t, err) + + // host has neither required label β€” out of scope + scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + require.NoError(t, err) + require.False(t, scoped) + + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software, vppApp.Name) + + // add label5 only β€” still missing label6, so still out of scope + require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label5.ID})) + host.LabelUpdatedAt = time.Now() + err = ds.UpdateHost(ctx, host) + require.NoError(t, err) + time.Sleep(time.Second) + + scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + require.NoError(t, err) + require.False(t, scoped) + + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software, vppApp.Name) + + // add label6 β€” host now has both required labels, so VPP app is in scope + require.NoError(t, ds.AddLabelsToHost(ctx, host.ID, []uint{label6.ID})) + host.LabelUpdatedAt = time.Now() + err = ds.UpdateHost(ctx, host) + require.NoError(t, err) + time.Sleep(time.Second) + + scoped, err = ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID) + require.NoError(t, err) + require.True(t, scoped) + + software, _, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + checkSoftware(software) } func testListHostSoftwareLastOpenedAt(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/software_title_icons.go b/server/datastore/mysql/software_title_icons.go index c2b4f3d9106..0783bec10bb 100644 --- a/server/datastore/mysql/software_title_icons.go +++ b/server/datastore/mysql/software_title_icons.go @@ -115,7 +115,7 @@ func (ds *Datastore) DeleteSoftwareTitleIcon(ctx context.Context, teamID, titleI func (ds *Datastore) DeleteIconsAssociatedWithTitlesWithoutInstallers(ctx context.Context, teamID uint) error { _, err := ds.writer(ctx).ExecContext(ctx, `DELETE FROM software_title_icons WHERE team_id = ? - AND software_title_id NOT IN (SELECT title_id FROM vpp_apps va JOIN vpp_apps_teams vat + AND software_title_id NOT IN (SELECT title_id FROM vpp_apps va JOIN vpp_apps_teams vat ON vat.adam_id = va.adam_id AND vat.platform = va.platform WHERE global_or_team_id = ?) AND software_title_id NOT IN (SELECT title_id FROM software_installers WHERE global_or_team_id = ?) AND software_title_id NOT IN (SELECT title_id FROM in_house_apps WHERE global_or_team_id = ?)`, @@ -172,9 +172,10 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te } type ActivitySoftwareLabel struct { - ID uint `db:"id"` - Name string `db:"name"` - Exclude bool `db:"exclude"` + ID uint `db:"id"` + Name string `db:"name"` + Exclude bool `db:"exclude"` + RequireAll bool `db:"require_all"` } var labels []ActivitySoftwareLabel if details.SoftwareInstallerID != nil { @@ -182,7 +183,8 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te SELECT labels.id AS id, labels.name AS name, - software_installer_labels.exclude AS exclude + software_installer_labels.exclude AS exclude, + software_installer_labels.require_all AS require_all FROM software_installer_labels INNER JOIN labels ON software_installer_labels.label_id = labels.id WHERE software_installer_id = ? @@ -196,7 +198,8 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te SELECT labels.id AS id, labels.name AS name, - vpp_app_team_labels.exclude AS exclude + vpp_app_team_labels.exclude AS exclude, + vpp_app_team_labels.require_all AS require_all FROM vpp_app_team_labels INNER JOIN labels ON vpp_app_team_labels.label_id = labels.id WHERE vpp_app_team_id = ? @@ -210,7 +213,8 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te SELECT labels.id AS id, labels.name AS name, - in_house_app_labels.exclude AS exclude + in_house_app_labels.exclude AS exclude, + in_house_app_labels.require_all AS require_all FROM in_house_app_labels INNER JOIN labels ON in_house_app_labels.label_id = labels.id WHERE in_house_app_id = ? @@ -221,16 +225,28 @@ func (ds *Datastore) ActivityDetailsForSoftwareTitleIcon(ctx context.Context, te } for _, l := range labels { - if l.Exclude { + switch { + case l.Exclude && !l.RequireAll: details.LabelsExcludeAny = append(details.LabelsExcludeAny, fleet.ActivitySoftwareLabel{ ID: l.ID, Name: l.Name, }) - } else { + + case !l.Exclude && l.RequireAll: + details.LabelsIncludeAll = append(details.LabelsIncludeAll, fleet.ActivitySoftwareLabel{ + ID: l.ID, + Name: l.Name, + }) + + case !l.Exclude && !l.RequireAll: details.LabelsIncludeAny = append(details.LabelsIncludeAny, fleet.ActivitySoftwareLabel{ ID: l.ID, Name: l.Name, }) + + default: + // should never happen, we don't support ExcludeAll currently + ds.logger.ErrorContext(ctx, "unsupported label condition 'exclude-all' encountered for software", "title_id", titleID, "label_id", l.ID) } } diff --git a/server/datastore/mysql/software_title_icons_test.go b/server/datastore/mysql/software_title_icons_test.go index 9fff424df84..a6f1b014a27 100644 --- a/server/datastore/mysql/software_title_icons_test.go +++ b/server/datastore/mysql/software_title_icons_test.go @@ -303,11 +303,18 @@ func testActivityDetailsForSoftwareTitleIcon(t *testing.T, ds *Datastore) { "INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?)", installerID, label1.ID, true) require.NoError(t, err) - // Insert include label + // Insert include any label _, err = ds.writer(ctx).ExecContext(ctx, "INSERT INTO software_installer_labels (software_installer_id, label_id, exclude) VALUES (?, ?, ?)", installerID, label2.ID, false) require.NoError(t, err) + // Insert include all label + label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3"}) + require.NoError(t, err) + _, err = ds.writer(ctx).ExecContext(ctx, + "INSERT INTO software_installer_labels (software_installer_id, label_id, exclude, require_all) VALUES (?, ?, ?, ?)", + installerID, label3.ID, false, true) + require.NoError(t, err) _, err = ds.CreateOrUpdateSoftwareTitleIcon(ctx, &fleet.UploadSoftwareTitleIconPayload{ TeamID: teamID, @@ -335,6 +342,8 @@ func testActivityDetailsForSoftwareTitleIcon(t *testing.T, ds *Datastore) { require.Equal(t, "label1", activity.LabelsExcludeAny[0].Name) require.Len(t, activity.LabelsIncludeAny, 1) require.Equal(t, "label2", activity.LabelsIncludeAny[0].Name) + require.Len(t, activity.LabelsIncludeAll, 1) + require.Equal(t, "label3", activity.LabelsIncludeAll[0].Name) }}, {"vpp app", func(ds *Datastore) { teamID, titleID, err = createTeamAndSoftwareTitle(t, ctx, ds) @@ -381,11 +390,18 @@ func testActivityDetailsForSoftwareTitleIcon(t *testing.T, ds *Datastore) { "INSERT INTO vpp_app_team_labels (vpp_app_team_id, label_id, exclude) VALUES (?, ?, ?)", vppApp.VPPAppTeam.AppTeamID, label1.ID, true) require.NoError(t, err) - // Insert include label + // Insert include any label _, err = ds.writer(ctx).ExecContext(ctx, "INSERT INTO vpp_app_team_labels (vpp_app_team_id, label_id, exclude) VALUES (?, ?, ?)", vppApp.VPPAppTeam.AppTeamID, label2.ID, false) require.NoError(t, err) + // Insert include all label + label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3"}) + require.NoError(t, err) + _, err = ds.writer(ctx).ExecContext(ctx, + "INSERT INTO vpp_app_team_labels (vpp_app_team_id, label_id, exclude, require_all) VALUES (?, ?, ?, ?)", + vppApp.VPPAppTeam.AppTeamID, label3.ID, false, true) + require.NoError(t, err) _, err = ds.CreateOrUpdateSoftwareTitleIcon(ctx, &fleet.UploadSoftwareTitleIconPayload{ TeamID: teamID, @@ -413,6 +429,8 @@ func testActivityDetailsForSoftwareTitleIcon(t *testing.T, ds *Datastore) { require.Equal(t, "label1", activity.LabelsExcludeAny[0].Name) require.Len(t, activity.LabelsIncludeAny, 1) require.Equal(t, "label2", activity.LabelsIncludeAny[0].Name) + require.Len(t, activity.LabelsIncludeAll, 1) + require.Equal(t, "label3", activity.LabelsIncludeAll[0].Name) }}, {"team id 0", func(ds *Datastore) { user := test.NewUser(t, ds, "user1", "user1@example.com", false) @@ -480,6 +498,7 @@ func testActivityDetailsForSoftwareTitleIcon(t *testing.T, ds *Datastore) { require.Nil(t, activity.Platform) require.Nil(t, activity.LabelsExcludeAny) require.Nil(t, activity.LabelsIncludeAny) + require.Nil(t, activity.LabelsIncludeAll) }}, {"in house app", func(ds *Datastore) { user := test.NewUser(t, ds, "user1", "user1@example.com", false) @@ -519,11 +538,18 @@ func testActivityDetailsForSoftwareTitleIcon(t *testing.T, ds *Datastore) { "INSERT INTO in_house_app_labels (in_house_app_id, label_id, exclude) VALUES (?, ?, ?)", installerID, label1.ID, true) require.NoError(t, err) - // Insert include label + // Insert include any label _, err = ds.writer(ctx).ExecContext(ctx, "INSERT INTO in_house_app_labels (in_house_app_id, label_id, exclude) VALUES (?, ?, ?)", installerID, label2.ID, false) require.NoError(t, err) + // Insert include all label + label3, err := ds.NewLabel(ctx, &fleet.Label{Name: "label3"}) + require.NoError(t, err) + _, err = ds.writer(ctx).ExecContext(ctx, + "INSERT INTO in_house_app_labels (in_house_app_id, label_id, exclude, require_all) VALUES (?, ?, ?, ?)", + installerID, label3.ID, false, true) + require.NoError(t, err) _, err = ds.CreateOrUpdateSoftwareTitleIcon(ctx, &fleet.UploadSoftwareTitleIconPayload{ TeamID: teamID, @@ -551,6 +577,8 @@ func testActivityDetailsForSoftwareTitleIcon(t *testing.T, ds *Datastore) { require.Equal(t, "label1", activity.LabelsExcludeAny[0].Name) require.Len(t, activity.LabelsIncludeAny, 1) require.Equal(t, "label2", activity.LabelsIncludeAny[0].Name) + require.Len(t, activity.LabelsIncludeAll, 1) + require.Equal(t, "label3", activity.LabelsIncludeAll[0].Name) }}, } diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index cc33599a5cc..d556f99e9fb 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -62,21 +62,32 @@ WHERE if err != nil { return nil, ctxerr.Wrap(ctx, err, "get vpp app labels") } - var exclAny, inclAny []fleet.SoftwareScopeLabel + var exclAny, inclAny, inclAll []fleet.SoftwareScopeLabel for _, l := range labels { - if l.Exclude { + switch { + case l.Exclude && !l.RequireAll: exclAny = append(exclAny, l) - } else { + case !l.Exclude && l.RequireAll: + inclAll = append(inclAll, l) + case !l.Exclude && !l.RequireAll: inclAny = append(inclAny, l) + default: + ds.logger.WarnContext(ctx, "vpp app has an unsupported label scope", "vpp_apps_teams_id", app.VPPAppsTeamsID, "invalid_label", fmt.Sprintf("%#v", l)) } } - if len(inclAny) > 0 && len(exclAny) > 0 { - // there's a bug somewhere - ds.logger.WarnContext(ctx, "vpp app has both include and exclude labels", "vpp_apps_teams_id", app.VPPAppsTeamsID, "include", fmt.Sprintf("%v", inclAny), "exclude", fmt.Sprintf("%v", exclAny)) + var count int + for _, set := range [][]fleet.SoftwareScopeLabel{exclAny, inclAny, inclAll} { + if len(set) > 0 { + count++ + } + } + if count > 1 { + ds.logger.WarnContext(ctx, "vpp app has more than one scope of labels", "vpp_apps_teams_id", app.VPPAppsTeamsID, "include_any", fmt.Sprintf("%v", inclAny), "exclude_any", fmt.Sprintf("%v", exclAny), "include_all", fmt.Sprintf("%v", inclAll)) } app.LabelsExcludeAny = exclAny app.LabelsIncludeAny = inclAny + app.LabelsIncludeAll = inclAll categories, err := ds.getCategoriesForVPPApp(ctx, app.VPPAppsTeamsID) if err != nil { @@ -146,7 +157,8 @@ SELECT label_id, exclude, l.name AS label_name, - va.title_id AS title_id + va.title_id AS title_id, + require_all FROM vpp_app_team_labels vatl JOIN vpp_apps_teams vat ON vat.id = vatl.vpp_app_team_id @@ -344,18 +356,29 @@ func (ds *Datastore) getExistingLabels(ctx context.Context, vppAppTeamID uint) ( } var labels fleet.LabelIdentsWithScope - var exclAny, inclAny []fleet.SoftwareScopeLabel + var exclAny, inclAny, inclAll []fleet.SoftwareScopeLabel for _, l := range existingLabels { - if l.Exclude { + switch { + case l.Exclude && !l.RequireAll: exclAny = append(exclAny, l) - } else { + case !l.Exclude && l.RequireAll: + inclAll = append(inclAll, l) + case !l.Exclude && !l.RequireAll: inclAny = append(inclAny, l) + default: + ds.logger.WarnContext(ctx, "vpp app has an unsupported existing label scope", "vpp_apps_teams_id", vppAppTeamID, "invalid_label", fmt.Sprintf("%#v", l)) } } - if len(inclAny) > 0 && len(exclAny) > 0 { + var count int + for _, set := range [][]fleet.SoftwareScopeLabel{exclAny, inclAny, inclAll} { + if len(set) > 0 { + count++ + } + } + if count > 1 { // there's a bug somewhere - return nil, ctxerr.New(ctx, "found both include and exclude labels on a vpp app") + return nil, ctxerr.New(ctx, "found labels for more than one scope on a vpp app") } switch { @@ -374,6 +397,15 @@ func (ds *Datastore) getExistingLabels(ctx context.Context, vppAppTeamID uint) ( labels.ByName[l.LabelName] = fleet.LabelIdent{LabelName: l.LabelName, LabelID: l.LabelID} } return &labels, nil + + case len(inclAll) > 0: + labels.LabelScope = fleet.LabelScopeIncludeAll + labels.ByName = make(map[string]fleet.LabelIdent, len(inclAll)) + for _, l := range inclAll { + labels.ByName[l.LabelName] = fleet.LabelIdent{LabelName: l.LabelName, LabelID: l.LabelID} + } + return &labels, nil + default: return nil, nil } @@ -2329,9 +2361,8 @@ FROM ( vpp_app_team_labels vatl LEFT JOIN vpp_apps_teams ON vpp_apps_teams.id = vatl.vpp_app_team_id JOIN hosts ON hosts.id = ? AND hosts.team_id <=> vpp_apps_teams.team_id - LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id - AND lm.host_id = ? - WHERE vatl.exclude = 0 AND vpp_apps_teams.platform = 'android' + LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id AND lm.host_id = ? + WHERE vatl.exclude = 0 AND vatl.require_all = 0 AND vpp_apps_teams.platform = 'android' GROUP BY installable_id HAVING count_installer_labels > 0 @@ -2364,20 +2395,39 @@ FROM ( vpp_apps_teams.adam_id AS installable_id FROM vpp_app_team_labels vatl - LEFT JOIN vpp_apps_teams ON vpp_apps_teams.id = vatl.vpp_app_team_id - JOIN hosts ON hosts.id = ? AND hosts.team_id <=> vpp_apps_teams.team_id - LEFT OUTER JOIN labels lbl ON lbl.id = vatl.label_id - LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id - AND lm.host_id = ? - WHERE vatl.exclude = 1 AND vpp_apps_teams.platform = 'android' + LEFT JOIN vpp_apps_teams ON vpp_apps_teams.id = vatl.vpp_app_team_id + JOIN hosts ON hosts.id = ? AND hosts.team_id <=> vpp_apps_teams.team_id + LEFT OUTER JOIN labels lbl ON lbl.id = vatl.label_id + LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id AND lm.host_id = ? + WHERE vatl.exclude = 1 AND vatl.require_all = 0 AND vpp_apps_teams.platform = 'android' GROUP BY installable_id HAVING count_installer_labels > 0 AND count_installer_labels = count_host_updated_after_labels - AND count_host_labels = 0) t; + AND count_host_labels = 0 + + UNION + + -- include all + SELECT + COUNT(*) AS count_installer_labels, + COUNT(lm.label_id) AS count_host_labels, + 0 AS count_host_updated_after_labels, + vpp_apps_teams.adam_id AS installable_id + FROM + vpp_app_team_labels vatl + LEFT JOIN vpp_apps_teams ON vpp_apps_teams.id = vatl.vpp_app_team_id + JOIN hosts ON hosts.id = ? AND hosts.team_id <=> vpp_apps_teams.team_id + LEFT OUTER JOIN label_membership lm ON lm.label_id = vatl.label_id AND lm.host_id = ? + WHERE vatl.exclude = 0 AND vatl.require_all = 1 AND vpp_apps_teams.platform = 'android' + GROUP BY installable_id + HAVING + count_installer_labels > 0 + AND count_host_labels = count_installer_labels + ) t ` - err = sqlx.SelectContext(ctx, ds.reader(ctx), &applicationIDs, stmt, hostID, hostID, hostID, hostID, hostID, hostID) + err = sqlx.SelectContext(ctx, ds.reader(ctx), &applicationIDs, stmt, hostID, hostID, hostID, hostID, hostID, hostID, hostID, hostID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get in android apps in scope for host") } diff --git a/server/datastore/mysql/vpp_test.go b/server/datastore/mysql/vpp_test.go index 31f80a810a8..63c465079a4 100644 --- a/server/datastore/mysql/vpp_test.go +++ b/server/datastore/mysql/vpp_test.go @@ -575,6 +575,7 @@ func testVPPApps(t *testing.T, ds *Datastore) { require.Len(t, meta.LabelsIncludeAny, 2) require.Len(t, meta.LabelsExcludeAny, 0) + require.Len(t, meta.LabelsIncludeAll, 0) // insert a VPP app with exclude_any labels labeledApp = &fleet.VPPApp{ @@ -599,6 +600,7 @@ func testVPPApps(t *testing.T, ds *Datastore) { require.Len(t, meta.LabelsIncludeAny, 0) require.Len(t, meta.LabelsExcludeAny, 2) + require.Len(t, meta.LabelsIncludeAll, 0) }) // create a host with some non-VPP software @@ -1939,6 +1941,7 @@ func testSetTeamVPPAppsWithLabels(t *testing.T, ds *Datastore) { require.Len(t, app1Meta.LabelsIncludeAny, 2) require.Len(t, app1Meta.LabelsExcludeAny, 0) + require.Len(t, app1Meta.LabelsIncludeAll, 0) for _, l := range app1Meta.LabelsIncludeAny { _, ok := app1.VPPAppTeam.ValidatedLabels.ByName[l.LabelName] require.True(t, ok) @@ -1946,6 +1949,7 @@ func testSetTeamVPPAppsWithLabels(t *testing.T, ds *Datastore) { require.Len(t, app2Meta.LabelsExcludeAny, 2) require.Len(t, app2Meta.LabelsIncludeAny, 0) + require.Len(t, app2Meta.LabelsIncludeAll, 0) for _, l := range app2Meta.LabelsExcludeAny { _, ok := app2.VPPAppTeam.ValidatedLabels.ByName[l.LabelName] require.True(t, ok) @@ -1998,6 +2002,7 @@ func testSetTeamVPPAppsWithLabels(t *testing.T, ds *Datastore) { require.Len(t, app1Meta.LabelsIncludeAny, 0) require.Len(t, app1Meta.LabelsExcludeAny, 2) + require.Len(t, app1Meta.LabelsIncludeAll, 0) for _, l := range app1Meta.LabelsExcludeAny { _, ok := app1.VPPAppTeam.ValidatedLabels.ByName[l.LabelName] require.True(t, ok) @@ -2005,6 +2010,7 @@ func testSetTeamVPPAppsWithLabels(t *testing.T, ds *Datastore) { require.Len(t, app2Meta.LabelsExcludeAny, 0) require.Len(t, app2Meta.LabelsIncludeAny, 2) + require.Len(t, app2Meta.LabelsIncludeAll, 0) for _, l := range app2Meta.LabelsIncludeAny { _, ok := app2.VPPAppTeam.ValidatedLabels.ByName[l.LabelName] require.True(t, ok) diff --git a/server/fleet/activities.go b/server/fleet/activities.go index e7c7a3de9f5..299e1eb43c4 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -1091,6 +1091,7 @@ type ActivityTypeAddedSoftware struct { SoftwareTitleID uint `json:"software_title_id"` LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` + LabelsIncludeAll []ActivitySoftwareLabel `json:"labels_include_all,omitempty"` } func (a ActivityTypeAddedSoftware) ActivityName() string { @@ -1106,6 +1107,7 @@ type ActivityTypeEditedSoftware struct { SoftwareIconURL *string `json:"software_icon_url"` LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` + LabelsIncludeAll []ActivitySoftwareLabel `json:"labels_include_all,omitempty"` SoftwareTitleID uint `json:"software_title_id"` SoftwareDisplayName string `json:"software_display_name"` } @@ -1123,6 +1125,7 @@ type ActivityTypeDeletedSoftware struct { SoftwareIconURL *string `json:"software_icon_url"` LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` + LabelsIncludeAll []ActivitySoftwareLabel `json:"labels_include_all,omitempty"` } func (a ActivityTypeDeletedSoftware) ActivityName() string { @@ -1236,6 +1239,7 @@ type ActivityAddedAppStoreApp struct { SelfService bool `json:"self_service"` LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` + LabelsIncludeAll []ActivitySoftwareLabel `json:"labels_include_all,omitempty"` Configuration json.RawMessage `json:"configuration,omitempty"` } @@ -1252,6 +1256,7 @@ type ActivityDeletedAppStoreApp struct { SoftwareIconURL *string `json:"software_icon_url"` LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` + LabelsIncludeAll []ActivitySoftwareLabel `json:"labels_include_all,omitempty"` } func (a ActivityDeletedAppStoreApp) ActivityName() string { @@ -1308,6 +1313,7 @@ type ActivityEditedAppStoreApp struct { SoftwareIconURL *string `json:"software_icon_url"` LabelsIncludeAny []ActivitySoftwareLabel `json:"labels_include_any,omitempty"` LabelsExcludeAny []ActivitySoftwareLabel `json:"labels_exclude_any,omitempty"` + LabelsIncludeAll []ActivitySoftwareLabel `json:"labels_include_all,omitempty"` SoftwareDisplayName string `json:"software_display_name"` Configuration json.RawMessage `json:"configuration,omitempty"` AutoUpdateEnabled *bool `json:"auto_update_enabled,omitempty"` diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 8205ceb0ed0..81d96287e4e 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -439,6 +439,7 @@ type SoftwareInstallerPayload struct { InstallDuringSetup *bool `json:"install_during_setup"` // if nil, do not change saved value, otherwise set it LabelsIncludeAny []string `json:"labels_include_any"` LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAll []string `json:"labels_include_all"` // ValidatedLabels is a struct that contains the validated labels for the // software installer. It is nil if the labels have not been validated. ValidatedLabels *LabelIdentsWithScope diff --git a/server/fleet/service.go b/server/fleet/service.go index eb1d5a38146..2e8fa8963bf 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1362,7 +1362,7 @@ type Service interface { // Fleet-maintained apps // AddFleetMaintainedApp adds a Fleet-maintained app to the given team. - AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, automaticInstall bool, labelsIncludeAny, labelsExcludeAny []string) (uint, error) + AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, automaticInstall bool, labelsIncludeAny, labelsExcludeAny, labelsIncludeAll []string) (uint, error) // ListFleetMaintainedApps lists Fleet-maintained apps, including associated software title for supplied team ID (if any) ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error) // GetFleetMaintainedApp returns a Fleet-maintained app by ID, including associated software title for supplied team ID (if any) diff --git a/server/fleet/software.go b/server/fleet/software.go index dc7d518782d..42169afe3e5 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -807,6 +807,7 @@ type VPPBatchPayload struct { InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated LabelsExcludeAny []string `json:"labels_exclude_any"` LabelsIncludeAny []string `json:"labels_include_any"` + LabelsIncludeAll []string `json:"labels_include_all"` // Categories is the list of names of software categories associated with this VPP app. Categories []string `json:"categories"` DisplayName string `json:"display_name"` @@ -834,6 +835,7 @@ type VPPBatchPayloadWithPlatform struct { InstallDuringSetup *bool `json:"install_during_setup"` // keep saved value if nil, otherwise set as indicated LabelsExcludeAny []string `json:"labels_exclude_any"` LabelsIncludeAny []string `json:"labels_include_any"` + LabelsIncludeAll []string `json:"labels_include_all"` // Categories is the list of names of software categories associated with this VPP app. Categories []string `json:"categories"` // CategoryIDs is the list of IDs of software categories associated with this VPP app. diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 47e1a8258bf..e464c7c3ffb 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -123,6 +123,8 @@ type SoftwareInstaller struct { LabelsIncludeAny []SoftwareScopeLabel `json:"labels_include_any" db:"labels_include_any"` // LabelsExcludeAny is the list of "exclude any" labels for this software installer (if not nil). LabelsExcludeAny []SoftwareScopeLabel `json:"labels_exclude_any" db:"labels_exclude_any"` + // LabelsIncludeAll is the list of "include all" labels for this software installer (if not nil). + LabelsIncludeAll []SoftwareScopeLabel `json:"labels_include_all" db:"labels_include_all"` // Source is the osquery source for this software. Source string `json:"-" db:"source"` // Categories is the list of categories to which this software belongs: e.g. "Productivity", @@ -522,6 +524,7 @@ type UploadSoftwareInstallerPayload struct { InstallDuringSetup *bool // keep saved value if nil, otherwise set as indicated LabelsIncludeAny []string // names of "include any" labels LabelsExcludeAny []string // names of "exclude any" labels + LabelsIncludeAll []string // names of "include all" labels // ValidatedLabels is a struct that contains the validated labels for the software installer. It // is nil if the labels have not been validated. ValidatedLabels *LabelIdentsWithScope @@ -605,6 +608,7 @@ type UpdateSoftwareInstallerPayload struct { UpgradeCode string LabelsIncludeAny []string // names of "include any" labels LabelsExcludeAny []string // names of "exclude any" labels + LabelsIncludeAll []string // names of "include all" labels // ValidatedLabels is a struct that contains the validated labels for the software installer. It // can be nil if the labels have not been validated or if the labels are not being updated. ValidatedLabels *LabelIdentsWithScope @@ -617,8 +621,8 @@ type UpdateSoftwareInstallerPayload struct { func (u *UpdateSoftwareInstallerPayload) IsNoopPayload(existing *SoftwareTitle) bool { return u.SelfService == nil && u.InstallerFile == nil && u.PreInstallQuery == nil && u.InstallScript == nil && u.PostInstallScript == nil && u.UninstallScript == nil && - u.LabelsIncludeAny == nil && u.LabelsExcludeAny == nil && u.DisplayName == nil && - u.CategoryIDs == nil + u.LabelsIncludeAny == nil && u.LabelsExcludeAny == nil && u.LabelsIncludeAll == nil && + u.DisplayName == nil && u.CategoryIDs == nil } // DownloadSoftwareInstallerPayload is the payload for downloading a software installer. @@ -781,6 +785,7 @@ type SoftwarePackageSpec struct { UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"` LabelsIncludeAny []string `json:"labels_include_any"` LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAll []string `json:"labels_include_all"` InstallDuringSetup optjson.Bool `json:"setup_experience"` Icon TeamSpecSoftwareAsset `json:"icon"` @@ -813,8 +818,8 @@ func (spec SoftwarePackageSpec) ResolveSoftwarePackagePaths(baseDir string) Soft } func (spec SoftwarePackageSpec) IncludesFieldsDisallowedInPackageFile() bool { - return len(spec.LabelsExcludeAny) > 0 || len(spec.LabelsIncludeAny) > 0 || len(spec.Categories) > 0 || - spec.SelfService || spec.InstallDuringSetup.Valid + return len(spec.LabelsExcludeAny) > 0 || len(spec.LabelsIncludeAny) > 0 || len(spec.LabelsIncludeAll) > 0 || + len(spec.Categories) > 0 || spec.SelfService || spec.InstallDuringSetup.Valid } func resolveApplyRelativePath(baseDir string, path string) string { @@ -835,6 +840,7 @@ type MaintainedAppSpec struct { UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"` LabelsIncludeAny []string `json:"labels_include_any"` LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAll []string `json:"labels_include_all"` Categories []string `json:"categories"` InstallDuringSetup optjson.Bool `json:"setup_experience"` Icon TeamSpecSoftwareAsset `json:"icon"` @@ -851,6 +857,7 @@ func (spec MaintainedAppSpec) ToSoftwarePackageSpec() SoftwarePackageSpec { SelfService: spec.SelfService, LabelsIncludeAny: spec.LabelsIncludeAny, LabelsExcludeAny: spec.LabelsExcludeAny, + LabelsIncludeAll: spec.LabelsIncludeAll, InstallDuringSetup: spec.InstallDuringSetup, Icon: spec.Icon, Categories: spec.Categories, @@ -1076,10 +1083,11 @@ func NewTempFileReader(from io.Reader, tempDirFn func() string) (*TempFileReader // NOTE: depending on how/where this struct is used, fields MAY BE // UNRELIABLE insofar as they represent default, empty values. type SoftwareScopeLabel struct { - LabelName string `db:"label_name" json:"name"` - LabelID uint `db:"label_id" json:"id"` // label id in database, which may be the empty value in some cases where id is not known in advance (e.g., if labels are created during gitops processing) - Exclude bool `db:"exclude" json:"-"` // not rendered in JSON, used when processing LabelsIncludeAny and LabelsExcludeAny on parent title (may be the empty value in some cases) - TitleID uint `db:"title_id" json:"-"` // not rendered in JSON, used to store the associated title ID (may be the empty value in some cases) + LabelName string `db:"label_name" json:"name"` + LabelID uint `db:"label_id" json:"id"` // label id in database, which may be the empty value in some cases where id is not known in advance (e.g., if labels are created during gitops processing) + Exclude bool `db:"exclude" json:"-"` // not rendered in JSON, used when processing LabelsIncludeAll, LabelsIncludeAny and LabelsExcludeAny on parent title (may be the empty value in some cases) + TitleID uint `db:"title_id" json:"-"` // not rendered in JSON, used to store the associated title ID (may be the empty value in some cases) + RequireAll bool `db:"require_all" json:"-"` // not rendered in JSON, used when processing LabelsIncludeAll, LabelsIncludeAny and LabelsExcludeAny on parent title (may be the empty value in some cases) } // Max total attempts (including initial) for a non-policy software install. diff --git a/server/fleet/software_title_icons.go b/server/fleet/software_title_icons.go index b502812dee0..182873c3a3f 100644 --- a/server/fleet/software_title_icons.go +++ b/server/fleet/software_title_icons.go @@ -67,4 +67,5 @@ type DetailsForSoftwareIconActivity struct { Platform *InstallableDevicePlatform `json:"platform"` LabelsIncludeAny []ActivitySoftwareLabel `db:"-"` LabelsExcludeAny []ActivitySoftwareLabel `db:"-"` + LabelsIncludeAll []ActivitySoftwareLabel `db:"-"` } diff --git a/server/fleet/teams.go b/server/fleet/teams.go index bee65ada9c2..9a0f5867797 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -274,6 +274,7 @@ type TeamSpecAppStoreApp struct { SelfService bool `json:"self_service"` LabelsIncludeAny []string `json:"labels_include_any"` LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAll []string `json:"labels_include_all"` // Categories is the list of names of software categories associated with this VPP app. Categories []string `json:"categories"` // InstallDuringSetup indicates whether a package should be incorporated into setup experience; @@ -332,7 +333,7 @@ func (t *TeamMDM) Copy() *TeamMDM { clone := *t - // EnableDiskEncryption, MacOSUpdates and MacOSSetup don't have fields that + // EnableDiskEncryption, MacOS/IOS/IPadOS/WindowsUpdates don't have fields that // require cloning (all fields are basic value types, no // pointers/slices/maps). diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index 07a770c7fac..ec132992a57 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -30,14 +30,18 @@ type VPPAppTeam struct { // to false), while if not nil, it will update the flag's value in the DB. InstallDuringSetup *bool `db:"install_during_setup" json:"-"` // LabelsIncludeAny are the names of labels associated with this app. If a host has any of - // these labels, the app is in scope for that host. If this field is set, LabelsExcludeAny + // these labels, the app is in scope for that host. If this field is set, other label fields // cannot be set. LabelsIncludeAny []string `json:"labels_include_any"` // LabelsExcludeAny are the names of labels associated with this app. If a host has any of - // these labels, the app is out of scope for that host. If this field is set, LabelsIncludeAny + // these labels, the app is out of scope for that host. If this field is set, other label fields // cannot be set. LabelsExcludeAny []string `json:"labels_exclude_any"` - // ValidatedLabels are the labels (either include or exclude any) that have been validated by + // LabelsIncludeAll are the names of labels associated with this app. If a host has all of + // these labels, the app is in scope for that host. If this field is set, other label fields + // cannot be set. + LabelsIncludeAll []string `json:"labels_include_all"` + // ValidatedLabels are the labels (either include any/all or exclude any) that have been validated by // Fleet as being valid labels. This field is only used internally. ValidatedLabels *LabelIdentsWithScope `json:"-"` // AddAutoInstallPolicy indicates whether or not we should create an auto-install policy for @@ -115,6 +119,8 @@ type VPPAppStoreApp struct { LabelsIncludeAny []SoftwareScopeLabel `json:"labels_include_any" db:"labels_include_any"` // LabelsExcludeAny is the list of "exclude any" labels for this app store app (if not nil). LabelsExcludeAny []SoftwareScopeLabel `json:"labels_exclude_any" db:"labels_exclude_any"` + // LabelsIncludeAll is the list of "include all" labels for this app store app (if not nil). + LabelsIncludeAll []SoftwareScopeLabel `json:"labels_include_all" db:"labels_include_all"` // BundleIdentifier is the bundle identifier for this app. BundleIdentifier string `json:"-" db:"bundle_identifier"` // AddedAt is when the VPP app was added to the team @@ -188,6 +194,7 @@ type AppStoreAppUpdatePayload struct { SelfService *bool LabelsIncludeAny []string LabelsExcludeAny []string + LabelsIncludeAll []string Categories []string DisplayName *string Configuration json.RawMessage diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 7c64c34b723..3a16a7a656c 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -839,7 +839,7 @@ type MaybeCancelPendingSetupExperienceStepsFunc func(ctx context.Context, host * type IsAllSetupExperienceSoftwareRequiredFunc func(ctx context.Context, host *fleet.Host) (bool, error) -type AddFleetMaintainedAppFunc func(ctx context.Context, teamID *uint, appID uint, installScript string, preInstallQuery string, postInstallScript string, uninstallScript string, selfService bool, automaticInstall bool, labelsIncludeAny []string, labelsExcludeAny []string) (uint, error) +type AddFleetMaintainedAppFunc func(ctx context.Context, teamID *uint, appID uint, installScript string, preInstallQuery string, postInstallScript string, uninstallScript string, selfService bool, automaticInstall bool, labelsIncludeAny []string, labelsExcludeAny []string, labelsIncludeAll []string) (uint, error) type ListFleetMaintainedAppsFunc func(ctx context.Context, teamID *uint, opts fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) @@ -5063,11 +5063,11 @@ func (s *Service) IsAllSetupExperienceSoftwareRequired(ctx context.Context, host return s.IsAllSetupExperienceSoftwareRequiredFunc(ctx, host) } -func (s *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript string, preInstallQuery string, postInstallScript string, uninstallScript string, selfService bool, automaticInstall bool, labelsIncludeAny []string, labelsExcludeAny []string) (uint, error) { +func (s *Service) AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript string, preInstallQuery string, postInstallScript string, uninstallScript string, selfService bool, automaticInstall bool, labelsIncludeAny []string, labelsExcludeAny []string, labelsIncludeAll []string) (uint, error) { s.mu.Lock() s.AddFleetMaintainedAppFuncInvoked = true s.mu.Unlock() - return s.AddFleetMaintainedAppFunc(ctx, teamID, appID, installScript, preInstallQuery, postInstallScript, uninstallScript, selfService, automaticInstall, labelsIncludeAny, labelsExcludeAny) + return s.AddFleetMaintainedAppFunc(ctx, teamID, appID, installScript, preInstallQuery, postInstallScript, uninstallScript, selfService, automaticInstall, labelsIncludeAny, labelsExcludeAny, labelsIncludeAll) } func (s *Service) ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) { diff --git a/server/service/client.go b/server/service/client.go index 872b0b0d76c..0868908c3d6 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -898,6 +898,7 @@ func (c *Client) ApplyGroup( InstallDuringSetup: installDuringSetup, LabelsExcludeAny: app.LabelsExcludeAny, LabelsIncludeAny: app.LabelsIncludeAny, + LabelsIncludeAll: app.LabelsIncludeAll, Categories: app.Categories, DisplayName: app.DisplayName, IconPath: app.Icon.Path, @@ -1279,6 +1280,7 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri InstallDuringSetup: installDuringSetup, LabelsIncludeAny: si.LabelsIncludeAny, LabelsExcludeAny: si.LabelsExcludeAny, + LabelsIncludeAll: si.LabelsIncludeAll, SHA256: sha256Value, Categories: si.Categories, DisplayName: si.DisplayName, diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index ba1234412a9..72573dd5fd6 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12060,6 +12060,7 @@ func checkSoftwareInstaller(t *testing.T, ds *mysql.Datastore, payload *fleet.Up byName[l.LabelName] = struct{}{} require.Equal(t, *meta2.TitleID, l.TitleID) require.False(t, l.Exclude) + require.False(t, l.RequireAll) } require.Len(t, byName, len(payload.LabelsIncludeAny)) for _, l := range payload.LabelsIncludeAny { @@ -12074,6 +12075,7 @@ func checkSoftwareInstaller(t *testing.T, ds *mysql.Datastore, payload *fleet.Up byName[l.LabelName] = struct{}{} require.Equal(t, *meta2.TitleID, l.TitleID) require.True(t, l.Exclude) + require.False(t, l.RequireAll) } require.Len(t, byName, len(payload.LabelsExcludeAny)) for _, l := range payload.LabelsExcludeAny { @@ -12081,6 +12083,21 @@ func checkSoftwareInstaller(t *testing.T, ds *mysql.Datastore, payload *fleet.Up require.True(t, ok) } + // check labels include all + require.Len(t, meta2.LabelsIncludeAll, len(payload.LabelsIncludeAll)) + byName = make(map[string]struct{}, len(meta2.LabelsIncludeAll)) + for _, l := range meta2.LabelsIncludeAll { + byName[l.LabelName] = struct{}{} + require.Equal(t, *meta2.TitleID, l.TitleID) + require.False(t, l.Exclude) + require.True(t, l.RequireAll) + } + require.Len(t, byName, len(payload.LabelsIncludeAll)) + for _, l := range payload.LabelsIncludeAll { + _, ok := byName[l] + require.True(t, ok) + } + return meta.InstallerID, *meta.TitleID } @@ -12126,6 +12143,21 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD Query: "select 1", }}, http.StatusOK, &labelResp) require.NotZero(t, labelResp.Label.ID) + lblA := labelResp.Label + + s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ + Name: "label_b" + t.Name(), + Query: "select 1", + }}, http.StatusOK, &labelResp) + require.NotZero(t, labelResp.Label.ID) + lblB := labelResp.Label + + s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ + Name: "label_c" + t.Name(), + Query: "select 1", + }}, http.StatusOK, &labelResp) + require.NotZero(t, labelResp.Label.ID) + lblC := labelResp.Label payload := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "some install script", @@ -12141,6 +12173,60 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD LabelsIncludeAny: []string{t.Name()}, } + // validate that providing more than 1 type of label + // results in an error + testCases := []struct { + desc string + incAny []string + exclAny []string + incAll []string + }{ + { + desc: "include_any_exclude_any", + incAny: []string{lblA.Name}, + exclAny: []string{lblB.Name}, + }, + { + desc: "include_any_include_all", + incAny: []string{lblA.Name}, + incAll: []string{lblB.Name}, + }, + { + desc: "exclude_any_include_all", + exclAny: []string{lblA.Name}, + incAll: []string{lblB.Name}, + }, + { + desc: "all_types", + incAny: []string{lblA.Name}, + exclAny: []string{lblB.Name}, + incAll: []string{lblC.Name}, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some install script", + PreInstallQuery: "some pre install query", + PostInstallScript: "some post install script", + Filename: "ruby.deb", + // additional fields below are pre-populated so we can re-use the payload later for the test assertions + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + LabelsIncludeAny: tc.incAny, + LabelsIncludeAll: tc.incAll, + LabelsExcludeAny: tc.exclAny, + } + + s.uploadSoftwareInstaller(t, payload, http.StatusBadRequest, `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included.`) + + }) + } + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") // check the software installer @@ -12149,7 +12235,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // check activity activityData := fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "fleet_name": null, "fleet_id": null, "self_service": false, "software_title_id": %d, "labels_include_any": [{"id": %d, "name": %q}]}`, - titleID, labelResp.Label.ID, t.Name()) + titleID, lblA.ID, lblA.Name) s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0) // upload again fails @@ -12167,7 +12253,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD }, http.StatusOK, "") activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": null, "team_id": null, "fleet_name": null, "fleet_id": null, "self_service": true, "software_title_id": %d, "labels_include_any": [{"id": %d, "name": %q}], "software_display_name": ""}`, - titleID, labelResp.Label.ID, t.Name()) + titleID, lblA.ID, lblA.Name) s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), activityData, 0) // patch the software installer to change the labels body, headers := generateMultipartRequest(t, "", "", nil, s.token, map[string][]string{ @@ -12177,12 +12263,12 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers) expectedPayload := *payload expectedPayload.LabelsIncludeAny = nil - expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name} + expectedPayload.LabelsExcludeAny = []string{lblA.Name} checkSoftwareInstaller(t, s.ds, &expectedPayload) // Create a host and assign the label to it host := createOrbitEnrolledHost(t, "linux", "label_host", s.ds) - err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{labelResp.Label.ID: ptr.Bool(true)}, time.Now(), false) + err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lblA.ID: ptr.Bool(true)}, time.Now(), false) require.NoError(t, err) // Attempt to install. Should fail because label is "exclude any" @@ -12196,8 +12282,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD }) s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), body.Bytes(), http.StatusOK, headers) expectedPayload.PreInstallQuery = "some other pre install query" - expectedPayload.LabelsIncludeAny = nil // no change - expectedPayload.LabelsExcludeAny = []string{labelResp.Label.Name} // no change + expectedPayload.LabelsIncludeAny = nil // no change + expectedPayload.LabelsExcludeAny = []string{lblA.Name} // no change checkSoftwareInstaller(t, s.ds, &expectedPayload) // update the label to be "include any". This should allow for the installation to happen. @@ -12205,7 +12291,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD w3 := multipart.NewWriter(&b3) require.NoError(t, w3.WriteField("team_id", "0")) require.NoError(t, w3.WriteField("pre_install_query", "some other pre install query")) - require.NoError(t, w3.WriteField("labels_include_any", labelResp.Label.Name)) + require.NoError(t, w3.WriteField("labels_include_any", lblA.Name)) w3.Close() headers = map[string]string{ "Content-Type": w3.FormDataContentType(), @@ -12214,7 +12300,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD } s.DoRawWithHeaders("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), b3.Bytes(), http.StatusOK, headers) expectedPayload.PreInstallQuery = "some other pre install query" - expectedPayload.LabelsIncludeAny = []string{labelResp.Label.Name} + expectedPayload.LabelsIncludeAny = []string{lblA.Name} expectedPayload.LabelsExcludeAny = nil checkSoftwareInstaller(t, s.ds, &expectedPayload) @@ -12227,7 +12313,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": null, "team_id": null, "fleet_name": null, "fleet_id": null, "self_service": true, "labels_include_any": [{"id": %d, "name": %q}], "software_title_id": %d, "software_display_name": ""}`, - labelResp.Label.ID, labelResp.Label.Name, titleID) + lblA.ID, lblA.Name, titleID) s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), activityData, 0) // orbit-downloading fails with invalid orbit node key @@ -12246,7 +12332,69 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0") activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": null, "team_id": null, "fleet_name": null, "fleet_id": null, "self_service": true, "labels_include_any": [{"id": %d, "name": %q}]}`, - labelResp.Label.ID, labelResp.Label.Name) + lblA.ID, lblA.Name) + s.lastActivityMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), activityData, 0) + }) + + t.Run("upload no team software installer with labels_include_all", func(t *testing.T) { + // create a label to use for include_all scoping + var labelResp createLabelResponse + s.DoJSON("POST", "/api/latest/fleet/labels", &createLabelRequest{fleet.LabelPayload{ + Name: t.Name(), + Query: "select 1", + }}, http.StatusOK, &labelResp) + require.NotZero(t, labelResp.Label.ID) + + payload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some install script", + PreInstallQuery: "some pre install query", + Filename: "ruby.deb", + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + LabelsIncludeAll: []string{labelResp.Label.Name}, + } + + s.uploadSoftwareInstaller(t, payload, http.StatusOK, "") + + // check the software installer metadata: LabelsIncludeAll should be persisted + _, titleID := checkSoftwareInstaller(t, s.ds, payload) + + // check that the added-software activity carries labels_include_all + activityData := fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, + "team_id": null, "fleet_name": null, "fleet_id": null, "self_service": false, "software_title_id": %d, "labels_include_all": [{"id": %d, "name": %q}]}`, + titleID, labelResp.Label.ID, t.Name()) + s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0) + + // patch the installer to update an unrelated field; labels_include_all should be preserved + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + SelfService: ptr.Bool(true), + InstallScript: ptr.String("some install script"), + PreInstallQuery: ptr.String("some pre install query"), + Filename: "ruby.deb", + TitleID: titleID, + TeamID: nil, + }, http.StatusOK, "") + + // the edited-software activity should still carry labels_include_all + activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": null, + "team_id": null, "fleet_name": null, "fleet_id": null, "self_service": true, "software_title_id": %d, "labels_include_all": [{"id": %d, "name": %q}], "software_display_name": ""}`, + titleID, labelResp.Label.ID, t.Name()) + s.lastActivityMatches(fleet.ActivityTypeEditedSoftware{}.ActivityName(), activityData, 0) + + // create a host and assign the label β€” install should succeed since host has all required labels + host := createOrbitEnrolledHost(t, "linux", "include_all_label_host", s.ds) + err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{labelResp.Label.ID: ptr.Bool(true)}, time.Now(), false) + require.NoError(t, err) + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted) + + // delete the installer; the deleted-software activity should carry labels_include_all + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, "team_id", "0") + activityData = fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "software_icon_url": null, "team_name": null, + "team_id": null, "fleet_name": null, "fleet_id": null, "self_service": true, "labels_include_all": [{"id": %d, "name": %q}]}`, + labelResp.Label.ID, t.Name()) s.lastActivityMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), activityData, 0) }) @@ -13223,8 +13371,6 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { t := s.T() ctx := context.Background() - fmt.Printf("dev_mode.Env(\"FLEET_DEV_BATCH_RETRY_INTERVAL\"): %v\n", dev_mode.Env("FLEET_DEV_BATCH_RETRY_INTERVAL")) - // non-existent team s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{}, http.StatusNotFound, "team_name", "foo") @@ -13432,24 +13578,65 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = ptr.Bool(true) require.Equal(t, titlesResp, newTitlesResp) - // create some labels A and B + // create some labels A, B and C lblA, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "A"}) require.NoError(t, err) lblB, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "B"}) require.NoError(t, err) + lblC, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "C"}) + require.NoError(t, err) - // providing both labels include/exclude results in an error - softwareToInstall = []*fleet.SoftwareInstallerPayload{ - {URL: rubyURL, LabelsIncludeAny: []string{lblA.Name}, LabelsExcludeAny: []string{lblB.Name}}, + // validate that providing more than 1 type of label + // results in an error + testCases := []struct { + desc string + incAny []string + exclAny []string + incAll []string + }{ + { + desc: "include_any_exclude_any", + incAny: []string{lblA.Name}, + exclAny: []string{lblB.Name}, + }, + { + desc: "include_any_include_all", + incAny: []string{lblA.Name}, + incAll: []string{lblB.Name}, + }, + { + desc: "exclude_any_include_all", + exclAny: []string{lblA.Name}, + incAll: []string{lblB.Name}, + }, + { + desc: "all_types", + incAny: []string{lblA.Name}, + exclAny: []string{lblB.Name}, + incAll: []string{lblC.Name}, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + softwareToInstall = []*fleet.SoftwareInstallerPayload{ + { + URL: rubyURL, + LabelsIncludeAny: tc.incAny, + LabelsExcludeAny: tc.exclAny, + LabelsIncludeAll: tc.incAll, + }, + } + res := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest) + assert.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included.`) + + }) } - res := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest) - require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included.`) // providing a non-existing label results in an error softwareToInstall = []*fleet.SoftwareInstallerPayload{ {URL: rubyURL, LabelsIncludeAny: []string{"no-such-label"}}, } - res = s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest) + res := s.Do("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusBadRequest) require.Contains(t, extractServerErrorText(res.Body), `Couldn't update. Label "no-such-label" doesn't exist. Please remove the label from the software.`) // valid installer scoped by label @@ -13465,6 +13652,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { meta, err := s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, *packages[0].TitleID, false) require.NoError(t, err) require.Empty(t, meta.LabelsExcludeAny) + require.Empty(t, meta.LabelsIncludeAll) require.Len(t, meta.LabelsIncludeAny, 1) require.Equal(t, lblA.ID, meta.LabelsIncludeAny[0].LabelID) require.Equal(t, lblA.Name, meta.LabelsIncludeAny[0].LabelName) @@ -13533,7 +13721,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { // with a label softwareToInstall = []*fleet.SoftwareInstallerPayload{ - {Slug: &maintained1.Slug, LabelsIncludeAny: []string{lblA.Name}}, + {Slug: &maintained1.Slug, LabelsIncludeAll: []string{lblA.Name}}, } s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse) packages = waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, "", batchResponse.RequestUUID) @@ -13544,9 +13732,10 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { meta, err = s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, *packages[0].TitleID, false) require.NoError(t, err) require.Empty(t, meta.LabelsExcludeAny) - require.Len(t, meta.LabelsIncludeAny, 1) - require.Equal(t, lblA.ID, meta.LabelsIncludeAny[0].LabelID) - require.Equal(t, lblA.Name, meta.LabelsIncludeAny[0].LabelName) + require.Empty(t, meta.LabelsIncludeAny) + require.Len(t, meta.LabelsIncludeAll, 1) + require.Equal(t, lblA.ID, meta.LabelsIncludeAll[0].LabelID) + require.Equal(t, lblA.Name, meta.LabelsIncludeAll[0].LabelName) // maintained app with no_check for sha, latest for version maintained2, err := s.ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{ @@ -13611,7 +13800,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { http.DefaultTransport = mockTransport softwareToInstall = []*fleet.SoftwareInstallerPayload{ - {Slug: &maintained2.Slug}, + {Slug: &maintained2.Slug, LabelsIncludeAll: []string{lblA.Name}}, } s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse) packages = waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, "", batchResponse.RequestUUID) @@ -13627,6 +13816,16 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/software/titles/%d", *packages[0].TitleID), nil, http.StatusOK, &titleResponse, "team_id", "0") require.Equal(t, "1.0.0", titleResponse.SoftwareTitle.SoftwarePackage.Version) + meta, err = s.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, nil, *packages[0].TitleID, false) + require.NoError(t, err) + + // Validate labels + require.Empty(t, meta.LabelsExcludeAny) + require.Empty(t, meta.LabelsIncludeAny) + require.Len(t, meta.LabelsIncludeAll, 1) + require.Equal(t, lblA.ID, meta.LabelsIncludeAll[0].LabelID) + require.Equal(t, lblA.Name, meta.LabelsIncludeAll[0].LabelName) + http.DefaultTransport = oldTransport } @@ -18214,6 +18413,179 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInstallerID) require.NoError(t, err) require.NotNil(t, host1LastInstall) + + // --- include_all tests --- + // + // Use a dedicated team so we can reuse the ruby.deb and vim.deb testdata + // files without conflicting with the no-team installers already uploaded above. + var inclAllTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", fleet.Team{Name: t.Name() + "-include-all"}, http.StatusOK, &inclAllTeamResp) + inclAllTeam := inclAllTeamResp.Team + err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&inclAllTeam.ID, []uint{host.ID})) + require.NoError(t, err) + + // Host has lbl1 and lbl2. lbl3 is not on the host (lbl3 was never added + // via RecordLabelQueryExecutions in this test above). + + // Upload ruby.deb with labels_include_all: [lbl1, lbl2]. + // Host has both labels, so it IS in scope and install should be attempted. + rubyIncludeAllPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some deb install script", + Filename: "ruby.deb", + TeamID: &inclAllTeam.ID, + LabelsIncludeAll: []string{lbl1.Name, lbl2.Name}, + Platform: "linux", + } + s.uploadSoftwareInstaller(t, rubyIncludeAllPayload, http.StatusOK, "") + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "ruby", + "team_id", fmt.Sprint(inclAllTeam.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + rubyInclAllTitleID := resp.SoftwareTitles[0].ID + + var rubyInclAllDetail getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", rubyInclAllTitleID), nil, http.StatusOK, &rubyInclAllDetail, "team_id", fmt.Sprint(inclAllTeam.ID)) + require.NotNil(t, rubyInclAllDetail.SoftwareTitle) + require.NotNil(t, rubyInclAllDetail.SoftwareTitle.SoftwarePackage) + rubyInclAllInstallerID := rubyInclAllDetail.SoftwareTitle.SoftwarePackage.InstallerID + + policy3, err := s.ds.NewTeamPolicy(ctx, inclAllTeam.ID, nil, fleet.PolicyPayload{ + Name: "policy3", + Query: "SELECT 3;", + Platform: "linux", + }) + require.NoError(t, err) + + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", inclAllTeam.ID, policy3.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: rubyInclAllTitleID}, + }, + }, http.StatusOK, &mtplr) + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInclAllInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Send back a failed result for policy3. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host, + map[uint]*bool{ + policy3.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy3, err = s.ds.Policy(ctx, policy3.ID) + require.NoError(t, err) + // Host has all required labels, so the installer is in scope and the policy is marked failed. + require.Equal(t, uint(0), policy3.PassingHostCount) + require.Equal(t, uint(1), policy3.FailingHostCount) + + // Installation attempt was made for ruby, because host has all required labels. + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, rubyInclAllInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) + + // Upload vim.deb with labels_include_all: [lbl1, lbl3]. + // Host has lbl1 but NOT lbl3, so it is NOT in scope and install should be skipped. + vimIncludeAllPayload := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "some deb install script", + Filename: "vim.deb", + TeamID: &inclAllTeam.ID, + LabelsIncludeAll: []string{lbl1.Name, lbl3.Name}, + Platform: "linux", + } + s.uploadSoftwareInstaller(t, vimIncludeAllPayload, http.StatusOK, "") + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "query", "vim", + "team_id", fmt.Sprint(inclAllTeam.ID), + ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + vimInclAllTitleID := resp.SoftwareTitles[0].ID + + var vimInclAllDetail getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", vimInclAllTitleID), nil, http.StatusOK, &vimInclAllDetail, "team_id", fmt.Sprint(inclAllTeam.ID)) + require.NotNil(t, vimInclAllDetail.SoftwareTitle) + require.NotNil(t, vimInclAllDetail.SoftwareTitle.SoftwarePackage) + vimInclAllInstallerID := vimInclAllDetail.SoftwareTitle.SoftwarePackage.InstallerID + + policy4, err := s.ds.NewTeamPolicy(ctx, inclAllTeam.ID, nil, fleet.PolicyPayload{ + Name: "policy4", + Query: "SELECT 4;", + Platform: "linux", + }) + require.NoError(t, err) + + mtplr = modifyTeamPolicyResponse{} + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/policies/%d", inclAllTeam.ID, policy4.ID), modifyTeamPolicyRequest{ + ModifyPolicyPayload: fleet.ModifyPolicyPayload{ + SoftwareTitleID: optjson.Any[uint]{Set: true, Valid: true, Value: vimInclAllTitleID}, + }, + }, http.StatusOK, &mtplr) + + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInclAllInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Send back a failed result for policy4. + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host, + map[uint]*bool{ + policy4.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy4, err = s.ds.Policy(ctx, policy4.ID) + require.NoError(t, err) + // Host is missing lbl3, so the installer is not in scope. Policy should not be counted as failed. + require.Equal(t, uint(0), policy4.PassingHostCount) + require.Equal(t, uint(0), policy4.FailingHostCount) + + // No installation attempt for vim, because host is missing one of the required labels. + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInclAllInstallerID) + require.NoError(t, err) + require.Nil(t, host1LastInstall) + + // Now add lbl3 to the host and re-run the policy failure. vim should now be in scope. + err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl3.ID: ptr.Bool(true)}, time.Now(), false) + require.NoError(t, err) + + distributedResp = submitDistributedQueryResultsResponse{} + s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( + host, + map[uint]*bool{ + policy4.ID: ptr.Bool(false), + }, + ), http.StatusOK, &distributedResp) + err = s.ds.UpdateHostPolicyCounts(ctx) + require.NoError(t, err) + policy4, err = s.ds.Policy(ctx, policy4.ID) + require.NoError(t, err) + // Host now has all required labels, so the policy is marked as failed. + require.Equal(t, uint(0), policy4.PassingHostCount) + require.Equal(t, uint(1), policy4.FailingHostCount) + + // Installation attempt was made for vim now that the host has all required labels. + host1LastInstall, err = s.ds.GetHostLastInstallData(ctx, host.ID, vimInclAllInstallerID) + require.NoError(t, err) + require.NotNil(t, host1LastInstall) } func (s *integrationEnterpriseTestSuite) TestPolicyAutomationLabelScopingRetrigger() { @@ -19795,6 +20167,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { swTitle = titleResp.SoftwareTitle require.NotNil(t, swTitle.SoftwarePackage) require.Empty(t, swTitle.SoftwarePackage.LabelsExcludeAny) + require.Empty(t, swTitle.SoftwarePackage.LabelsIncludeAll) require.Len(t, swTitle.SoftwarePackage.LabelsIncludeAny, 2) gotNames := make(map[string]bool) for _, lbl := range swTitle.SoftwarePackage.LabelsIncludeAny { @@ -19822,7 +20195,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { req.LabelsExcludeAny = []string{lbl1.Name} addMAResp = addFleetMaintainedAppResponse{} r = s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", req, http.StatusBadRequest) - require.Contains(t, extractServerErrorText(r.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included`) + require.Contains(t, extractServerErrorText(r.Body), `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included`) } func (s *integrationEnterpriseTestSuite) TestUpgradeCodesFromMaintainedApps() { diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 447d7ffcaa1..61fb6e09ac6 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -12303,6 +12303,27 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { require.NoError(t, err) require.Len(t, assoc, 1) + // Add a label + clr := createLabelResponse{} + s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{ + LabelPayload: fleet.LabelPayload{ + Name: "label1" + t.Name(), + Query: "SELECT 1;", + }, + }, http.StatusOK, &clr) + + label1 := clr.Label + + clr = createLabelResponse{} + s.DoJSON("POST", "/api/latest/fleet/labels", createLabelRequest{ + LabelPayload: fleet.LabelPayload{ + Name: "label2" + t.Name(), + Query: "SELECT 2;", + }, + }, http.StatusOK, &clr) + + label2 := clr.Label + // Associating two apps we own beforeAssociation := time.Now() s.DoJSON("POST", @@ -12310,7 +12331,7 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { batchAssociateAppStoreAppsRequest{ Apps: []fleet.VPPBatchPayload{ {AppStoreID: s.appleVPPConfigSrvConfig.Assets[0].AdamID}, - {AppStoreID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, SelfService: true, Categories: []string{"Browsers"}}, + {AppStoreID: s.appleVPPConfigSrvConfig.Assets[1].AdamID, SelfService: true, Categories: []string{"Browsers"}, LabelsIncludeAll: []string{label1.Name, label2.Name}}, }, }, http.StatusOK, &batchAssociateResponse, "team_name", tmGood.Name, ) @@ -12332,6 +12353,11 @@ func (s *integrationMDMTestSuite) TestBatchAssociateAppStoreApps() { var getSWTitle getSoftwareTitleResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", st.ID), nil, http.StatusOK, &getSWTitle, "team_id", fmt.Sprint(tmGood.ID)) s.Assert().ElementsMatch([]string{"Browsers"}, getSWTitle.SoftwareTitle.AppStoreApp.Categories) + var labelNames []string + for _, l := range getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAll { + labelNames = append(labelNames, l.LabelName) + } + s.Assert().ElementsMatch([]string{label1.Name, label2.Name}, labelNames) } } @@ -13249,7 +13275,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { LabelsExcludeAny: []string{l2.Name}, } res := s.Do("POST", "/api/latest/fleet/software/app_store_apps", addAppReq, http.StatusBadRequest) - require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included`) + require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included`) // Now add it for real addAppReq.LabelsExcludeAny = []string{} @@ -13266,6 +13292,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { require.NotNil(t, getSWTitle.SoftwareTitle.AppStoreApp) require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.AdamID, includeAnyApp.AdamID) require.Empty(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsExcludeAny) + require.Empty(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAll) require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAny, []fleet.SoftwareScopeLabel{{LabelName: l1.Name, LabelID: l1.ID}}) // Add an app with exclude_any labels @@ -13299,6 +13326,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { require.NotNil(t, getSWTitle.SoftwareTitle.AppStoreApp) require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.AdamID, excludeAnyApp.AdamID) require.Empty(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAny) + require.Empty(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAll) require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsExcludeAny, []fleet.SoftwareScopeLabel{{LabelName: l2.Name, LabelID: l2.ID}}) require.True(t, getSWTitle.SoftwareTitle.AppStoreApp.SelfService) @@ -13335,7 +13363,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { updateAppReq.LabelsIncludeAny = []string{l1.Name} updateAppReq.LabelsExcludeAny = []string{l1.Name} res = s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", titleID), updateAppReq, http.StatusBadRequest) - require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_any" or "labels_exclude_any" can be included.`) + require.Contains(t, extractServerErrorText(res.Body), `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included.`) // Attempt to update with a non-existent label. Should fail. updateAppReq.LabelsExcludeAny = []string{} @@ -13352,6 +13380,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { require.Equal(t, updateAppResp.AppStoreApp.AdamID, excludeAnyApp.AdamID) require.Equal(t, updateAppResp.AppStoreApp.LabelsIncludeAny, []fleet.SoftwareScopeLabel{{LabelName: l2.Name, LabelID: l2.ID}}) require.Empty(t, updateAppResp.AppStoreApp.LabelsExcludeAny) + require.Empty(t, updateAppResp.AppStoreApp.LabelsIncludeAll) require.False(t, updateAppResp.AppStoreApp.SelfService) require.Equal(t, fleet.MacOSPlatform, updateAppResp.AppStoreApp.Platform) @@ -13367,6 +13396,7 @@ func (s *integrationMDMTestSuite) TestVPPApps() { require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.AdamID, excludeAnyApp.AdamID) require.Equal(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAny, []fleet.SoftwareScopeLabel{{LabelName: l2.Name, LabelID: l2.ID}}) require.Empty(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsExcludeAny) + require.Empty(t, getSWTitle.SoftwareTitle.AppStoreApp.LabelsIncludeAll) require.False(t, getSWTitle.SoftwareTitle.AppStoreApp.SelfService) // Attempt an install on the host. This should fail because the host doesn't have the label diff --git a/server/service/maintained_apps.go b/server/service/maintained_apps.go index 36c7b9dcdbd..06ec3954c8a 100644 --- a/server/service/maintained_apps.go +++ b/server/service/maintained_apps.go @@ -7,7 +7,7 @@ import ( "net/http" "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps" + maintained_apps "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps" ) type addFleetMaintainedAppRequest struct { @@ -20,6 +20,7 @@ type addFleetMaintainedAppRequest struct { UninstallScript string `json:"uninstall_script"` LabelsIncludeAny []string `json:"labels_include_any"` LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAll []string `json:"labels_include_all"` AutomaticInstall bool `json:"automatic_install"` Categories []string `json:"categories"` } @@ -82,6 +83,7 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc req.AutomaticInstall, req.LabelsIncludeAny, req.LabelsExcludeAny, + req.LabelsIncludeAll, ) if err != nil { if errors.Is(err, context.DeadlineExceeded) { @@ -93,7 +95,7 @@ func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc return &addFleetMaintainedAppResponse{SoftwareTitleID: titleId}, nil } -func (svc *Service) AddFleetMaintainedApp(ctx context.Context, _ *uint, _ uint, _, _, _, _ string, _ bool, _ bool, _, _ []string) (uint, error) { +func (svc *Service) AddFleetMaintainedApp(ctx context.Context, _ *uint, _ uint, _, _, _, _ string, _ bool, _ bool, _, _, _ []string) (uint, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 8feb6fc7f05..c5efc2c2b40 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -35,6 +35,7 @@ type uploadSoftwareInstallerRequest struct { UninstallScript string LabelsIncludeAny []string LabelsExcludeAny []string + LabelsIncludeAll []string AutomaticInstall bool } @@ -49,6 +50,7 @@ type updateSoftwareInstallerRequest struct { SelfService *bool LabelsIncludeAny []string LabelsExcludeAny []string + LabelsIncludeAll []string Categories []string DisplayName *string } @@ -144,8 +146,8 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http } // decode labels and categories - var inclAny, exclAny, categories []string - var existsInclAny, existsExclAny, existsCategories bool + var inclAny, exclAny, inclAll, categories []string + var existsInclAny, existsExclAny, existsInclAll, existsCategories bool inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)] switch { @@ -167,6 +169,16 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.LabelsExcludeAny = exclAny } + inclAll, existsInclAll = r.MultipartForm.Value[string(fleet.LabelsIncludeAll)] + switch { + case !existsInclAll: + decoded.LabelsIncludeAll = nil + case len(inclAll) == 1 && inclAll[0] == "": + decoded.LabelsIncludeAll = []string{} + default: + decoded.LabelsIncludeAll = inclAll + } + categories, existsCategories = r.MultipartForm.Value["categories"] switch { case !existsCategories: @@ -235,6 +247,7 @@ func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s SelfService: req.SelfService, LabelsIncludeAny: req.LabelsIncludeAny, LabelsExcludeAny: req.LabelsExcludeAny, + LabelsIncludeAll: req.LabelsIncludeAll, Categories: req.Categories, DisplayName: req.DisplayName, } @@ -355,8 +368,8 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http } // decode labels - var inclAny, exclAny []string - var existsInclAny, existsExclAny bool + var inclAny, exclAny, inclAll []string + var existsInclAny, existsExclAny, existsInclAll bool inclAny, existsInclAny = r.MultipartForm.Value[string(fleet.LabelsIncludeAny)] switch { @@ -378,6 +391,16 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.LabelsExcludeAny = exclAny } + inclAll, existsInclAll = r.MultipartForm.Value[string(fleet.LabelsIncludeAll)] + switch { + case !existsInclAll: + decoded.LabelsIncludeAll = nil + case len(inclAll) == 1 && inclAll[0] == "": + decoded.LabelsIncludeAll = []string{} + default: + decoded.LabelsIncludeAll = inclAll + } + val, ok = r.MultipartForm.Value["automatic_install"] if ok && len(val) > 0 && val[0] != "" { parsed, err := strconv.ParseBool(val[0]) @@ -434,6 +457,7 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s UninstallScript: req.UninstallScript, LabelsIncludeAny: req.LabelsIncludeAny, LabelsExcludeAny: req.LabelsExcludeAny, + LabelsIncludeAll: req.LabelsIncludeAll, AutomaticInstall: req.AutomaticInstall, } diff --git a/server/service/software_installers_test.go b/server/service/software_installers_test.go index 957ec495044..625569fd070 100644 --- a/server/service/software_installers_test.go +++ b/server/service/software_installers_test.go @@ -212,7 +212,7 @@ func TestValidateSoftwareLabels(t *testing.T) { t.Run("validate no update", func(t *testing.T) { t.Run("no auth context", func(t *testing.T) { - _, err := eeservice.ValidateSoftwareLabels(context.Background(), svc, nil, nil, nil) + _, err := eeservice.ValidateSoftwareLabels(context.Background(), svc, nil, nil, nil, nil) require.ErrorContains(t, err, "Authentication required") }) @@ -220,7 +220,7 @@ func TestValidateSoftwareLabels(t *testing.T) { ctx = authz_ctx.NewContext(ctx, &authCtx) t.Run("no auth checked", func(t *testing.T) { - _, err := eeservice.ValidateSoftwareLabels(ctx, svc, nil, nil, nil) + _, err := eeservice.ValidateSoftwareLabels(ctx, svc, nil, nil, nil, nil) require.ErrorContains(t, err, "Authentication required") }) @@ -267,6 +267,7 @@ func TestValidateSoftwareLabels(t *testing.T) { name string payloadIncludeAny []string payloadExcludeAny []string + payloadIncludeAll []string expectLabels map[string]fleet.LabelIdent expectScope fleet.LabelScope expectError string @@ -276,6 +277,7 @@ func TestValidateSoftwareLabels(t *testing.T) { nil, nil, nil, + nil, "", "", }, @@ -283,6 +285,7 @@ func TestValidateSoftwareLabels(t *testing.T) { "include labels", []string{"foo", "bar"}, nil, + nil, map[string]fleet.LabelIdent{ "foo": {LabelID: 1, LabelName: "foo"}, "bar": {LabelID: 2, LabelName: "bar"}, @@ -294,6 +297,7 @@ func TestValidateSoftwareLabels(t *testing.T) { "exclude labels", nil, []string{"bar", "baz"}, + nil, map[string]fleet.LabelIdent{ "bar": {LabelID: 2, LabelName: "bar"}, "baz": {LabelID: 3, LabelName: "baz"}, @@ -306,14 +310,16 @@ func TestValidateSoftwareLabels(t *testing.T) { []string{"foo"}, []string{"bar"}, nil, + nil, "", - `Only one of "labels_include_any" or "labels_exclude_any" can be included.`, + `Only one of "labels_include_all", "labels_include_any" or "labels_exclude_any" can be included.`, }, { "non-existent label", []string{"foo", "qux"}, nil, nil, + nil, "", `Couldn't update. Label "qux" doesn't exist. Please remove the label from the software`, }, @@ -321,6 +327,7 @@ func TestValidateSoftwareLabels(t *testing.T) { "duplicate label", []string{"foo", "foo"}, nil, + nil, map[string]fleet.LabelIdent{ "foo": {LabelID: 1, LabelName: "foo"}, }, @@ -332,6 +339,7 @@ func TestValidateSoftwareLabels(t *testing.T) { nil, []string{}, nil, + nil, "", "", }, @@ -340,13 +348,14 @@ func TestValidateSoftwareLabels(t *testing.T) { nil, []string{""}, nil, + nil, "", `Couldn't update. Label "" doesn't exist. Please remove the label from the software`, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - got, err := eeservice.ValidateSoftwareLabels(ctx, svc, nil, tt.payloadIncludeAny, tt.payloadExcludeAny) + got, err := eeservice.ValidateSoftwareLabels(ctx, svc, nil, tt.payloadIncludeAny, tt.payloadExcludeAny, tt.payloadIncludeAll) if tt.expectError != "" { require.Error(t, err) require.Contains(t, err.Error(), tt.expectError) @@ -362,7 +371,7 @@ func TestValidateSoftwareLabels(t *testing.T) { t.Run("validate update", func(t *testing.T) { t.Run("no auth context", func(t *testing.T) { - _, _, err := eeservice.ValidateSoftwareLabelsForUpdate(context.Background(), svc, nil, nil, nil) + _, _, err := eeservice.ValidateSoftwareLabelsForUpdate(context.Background(), svc, nil, nil, nil, nil) require.ErrorContains(t, err, "Authentication required") }) @@ -370,7 +379,7 @@ func TestValidateSoftwareLabels(t *testing.T) { ctx = authz_ctx.NewContext(ctx, &authCtx) t.Run("no auth checked", func(t *testing.T) { - _, _, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, nil, nil, nil) + _, _, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, nil, nil, nil, nil) require.ErrorContains(t, err, "Authentication required") }) @@ -402,6 +411,7 @@ func TestValidateSoftwareLabels(t *testing.T) { existingInstaller *fleet.SoftwareInstaller payloadIncludeAny []string payloadExcludeAny []string + payloadIncludeAll []string shouldUpdate bool expectLabels map[string]fleet.LabelIdent expectScope fleet.LabelScope @@ -412,6 +422,7 @@ func TestValidateSoftwareLabels(t *testing.T) { nil, nil, []string{"foo"}, + nil, false, nil, "", @@ -422,6 +433,7 @@ func TestValidateSoftwareLabels(t *testing.T) { &fleet.SoftwareInstaller{}, nil, nil, + nil, false, nil, "", @@ -435,6 +447,7 @@ func TestValidateSoftwareLabels(t *testing.T) { }, []string{"foo", "bar"}, nil, + nil, true, map[string]fleet.LabelIdent{ "foo": {LabelID: 1, LabelName: "foo"}, @@ -451,6 +464,7 @@ func TestValidateSoftwareLabels(t *testing.T) { }, nil, []string{"foo"}, + nil, true, map[string]fleet.LabelIdent{ "foo": {LabelID: 1, LabelName: "foo"}, @@ -466,6 +480,7 @@ func TestValidateSoftwareLabels(t *testing.T) { }, []string{}, nil, + nil, true, nil, "", @@ -479,6 +494,7 @@ func TestValidateSoftwareLabels(t *testing.T) { }, []string{"foo"}, nil, + nil, false, nil, "", @@ -488,7 +504,7 @@ func TestValidateSoftwareLabels(t *testing.T) { for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - shouldUpate, got, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, tt.existingInstaller, tt.payloadIncludeAny, tt.payloadExcludeAny) + shouldUpate, got, err := eeservice.ValidateSoftwareLabelsForUpdate(ctx, svc, tt.existingInstaller, tt.payloadIncludeAny, tt.payloadExcludeAny, tt.payloadIncludeAll) if tt.expectError != "" { require.Error(t, err) require.Contains(t, err.Error(), tt.expectError) diff --git a/server/service/software_title_icons_test.go b/server/service/software_title_icons_test.go index d41a0b72392..8b04a7285f5 100644 --- a/server/service/software_title_icons_test.go +++ b/server/service/software_title_icons_test.go @@ -379,6 +379,7 @@ func TestDeleteSoftwareTitleIcon(t *testing.T) { Platform: nil, LabelsIncludeAny: nil, LabelsExcludeAny: nil, + LabelsIncludeAll: nil, }, nil } ds.DeleteSoftwareTitleIconFunc = func(ctx context.Context, teamID uint, titleID uint) error { @@ -401,6 +402,7 @@ func TestDeleteSoftwareTitleIcon(t *testing.T) { SoftwareIconURL: ptr.String(""), LabelsIncludeAny: nil, LabelsExcludeAny: nil, + LabelsIncludeAll: nil, SoftwareTitleID: 1, } require.Equal(t, expectedActivity, capturedActivity) @@ -426,6 +428,7 @@ func TestDeleteSoftwareTitleIcon(t *testing.T) { Platform: &platform, LabelsIncludeAny: nil, LabelsExcludeAny: nil, + LabelsIncludeAll: nil, }, nil } ds.DeleteSoftwareTitleIconFunc = func(ctx context.Context, teamID uint, titleID uint) error { @@ -450,6 +453,7 @@ func TestDeleteSoftwareTitleIcon(t *testing.T) { SoftwareIconURL: ptr.String("fleetdm.com/icon.png"), // note this is supposed to be the vpp_apps.icon_url LabelsIncludeAny: nil, LabelsExcludeAny: nil, + LabelsIncludeAll: nil, } require.Equal(t, expectedActivity, capturedActivity) }, diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 4196a4f260a..cee5a3644d9 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -765,6 +765,11 @@ func (ts *withServer) uploadSoftwareInstallerWithErrorNameReason( require.NoError(t, w.WriteField("labels_exclude_any", l)) } } + if payload.LabelsIncludeAll != nil { + for _, l := range payload.LabelsIncludeAll { + require.NoError(t, w.WriteField("labels_include_all", l)) + } + } if payload.AutomaticInstall { require.NoError(t, w.WriteField("automatic_install", "true")) } @@ -847,6 +852,11 @@ func (ts *withServer) updateSoftwareInstaller( require.NoError(t, w.WriteField("labels_exclude_any", l)) } } + if payload.LabelsIncludeAll != nil { + for _, l := range payload.LabelsIncludeAll { + require.NoError(t, w.WriteField("labels_include_all", l)) + } + } if payload.Categories != nil { for _, c := range payload.Categories { require.NoError(t, w.WriteField("categories", c)) diff --git a/server/service/vpp.go b/server/service/vpp.go index c79cd42120d..ab5a92338e5 100644 --- a/server/service/vpp.go +++ b/server/service/vpp.go @@ -59,6 +59,7 @@ type addAppStoreAppRequest struct { AutomaticInstall bool `json:"automatic_install"` LabelsIncludeAny []string `json:"labels_include_any"` LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAll []string `json:"labels_include_all"` Categories []string `json:"categories"` Configuration json.RawMessage `json:"configuration,omitempty"` } @@ -77,6 +78,7 @@ func addAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fleet. SelfService: req.SelfService, LabelsIncludeAny: req.LabelsIncludeAny, LabelsExcludeAny: req.LabelsExcludeAny, + LabelsIncludeAll: req.LabelsIncludeAll, AddAutoInstallPolicy: req.AutomaticInstall, Categories: req.Categories, Configuration: req.Configuration, @@ -106,6 +108,7 @@ type updateAppStoreAppRequest struct { SelfService *bool `json:"self_service"` LabelsIncludeAny []string `json:"labels_include_any"` LabelsExcludeAny []string `json:"labels_exclude_any"` + LabelsIncludeAll []string `json:"labels_include_all"` Categories []string `json:"categories"` Configuration json.RawMessage `json:"configuration,omitempty"` DisplayName *string `json:"display_name"` @@ -133,6 +136,7 @@ func updateAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fle SelfService: req.SelfService, LabelsIncludeAny: req.LabelsIncludeAny, LabelsExcludeAny: req.LabelsExcludeAny, + LabelsIncludeAll: req.LabelsIncludeAll, Categories: req.Categories, Configuration: req.Configuration, DisplayName: req.DisplayName, diff --git a/tools/cloner-check/generated_files/teamconfig.txt b/tools/cloner-check/generated_files/teamconfig.txt index db2a0bad09f..dddee046ca1 100644 --- a/tools/cloner-check/generated_files/teamconfig.txt +++ b/tools/cloner-check/generated_files/teamconfig.txt @@ -108,6 +108,7 @@ github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec PostInstallScript f github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec UninstallScript fleet.TeamSpecSoftwareAsset github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec LabelsIncludeAny []string github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec LabelsExcludeAny []string +github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec LabelsIncludeAll []string github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec InstallDuringSetup optjson.Bool github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Icon fleet.TeamSpecSoftwareAsset github.com/fleetdm/fleet/v4/server/fleet/SoftwarePackageSpec Slug *string @@ -129,6 +130,7 @@ github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec PostInstallScript fle github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec UninstallScript fleet.TeamSpecSoftwareAsset github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec LabelsIncludeAny []string github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec LabelsExcludeAny []string +github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec LabelsIncludeAll []string github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec Categories []string github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec InstallDuringSetup optjson.Bool github.com/fleetdm/fleet/v4/server/fleet/MaintainedAppSpec Icon fleet.TeamSpecSoftwareAsset @@ -140,6 +142,7 @@ github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp AppStoreID string github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp SelfService bool github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp LabelsIncludeAny []string github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp LabelsExcludeAny []string +github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp LabelsIncludeAll []string github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp Categories []string github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp InstallDuringSetup optjson.Bool github.com/fleetdm/fleet/v4/server/fleet/TeamSpecAppStoreApp Icon fleet.TeamSpecSoftwareAsset From 8d646cd1653084f577bc40b23e64c8cf5cc4c744 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Wed, 18 Mar 2026 15:16:25 -0400 Subject: [PATCH 003/141] ui impl for labels include all (#41836) **Related issue:** Resolves #40724 # Checklist for submitter If some of the following don't apply, delete the relevant line. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually --- frontend/__mocks__/softwareMock.ts | 3 ++ frontend/interfaces/software.ts | 2 + .../EditAutoUpdateConfigModal.tests.tsx | 3 ++ .../SoftwareTitleDetailsPage/helpers.tests.ts | 2 + frontend/pages/SoftwarePage/helpers.tsx | 46 ++++++++++++++++--- frontend/services/entities/software.ts | 21 +++++++++ 6 files changed, 70 insertions(+), 7 deletions(-) diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts index a33f37453c6..e2a12f291ea 100644 --- a/frontend/__mocks__/softwareMock.ts +++ b/frontend/__mocks__/softwareMock.ts @@ -159,6 +159,7 @@ const DEFAULT_APP_STORE_APP_MOCK: IAppStoreApp = { failed: 3, }, labels_include_any: null, + labels_include_all: null, labels_exclude_any: null, }; @@ -183,6 +184,7 @@ const DEFAULT_APP_STORE_APP_ANDROID_MOCK: IAppStoreApp = { categories: null, labels_include_any: null, labels_exclude_any: null, + labels_include_all: null, configuration: '{ workProfileWidgets: "WORK_PROFILE_WIDGETS_ALLOWED" }', }; @@ -256,6 +258,7 @@ const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = { url: "https://fakeurl.testpackageurlforfalconapp.fake/test/package", hash_sha256: "abcd1234", labels_include_any: null, + labels_include_all: null, labels_exclude_any: null, install_during_setup: undefined, }; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 044e0ed0821..00af37af595 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -142,6 +142,7 @@ export interface ISoftwarePackage { automatic_install_policies?: ISoftwareInstallPolicy[] | null; install_during_setup?: boolean; labels_include_any: ILabelSoftwareTitle[] | null; + labels_include_all: ILabelSoftwareTitle[] | null; labels_exclude_any: ILabelSoftwareTitle[] | null; categories?: SoftwareCategory[] | null; fleet_maintained_app_id?: number | null; @@ -172,6 +173,7 @@ export interface IAppStoreApp { } | null; version?: string; labels_include_any: ILabelSoftwareTitle[] | null; + labels_include_all: ILabelSoftwareTitle[] | null; labels_exclude_any: ILabelSoftwareTitle[] | null; categories?: SoftwareCategory[] | null; configuration?: string; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx index 7debb4466cc..5a68e974a9d 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditAutoUpdateConfigModal/EditAutoUpdateConfigModal.tests.tsx @@ -473,6 +473,7 @@ describe("Edit Auto Update Config Modal", () => { auto_update_enabled: false, labels_include_any: [], labels_exclude_any: [], + labels_include_all: [], fleet_id: 1, }); }); @@ -514,6 +515,7 @@ describe("Edit Auto Update Config Modal", () => { auto_update_window_end: "04:00", labels_include_any: [], labels_exclude_any: [], + labels_include_all: [], fleet_id: 1, }); }); @@ -547,6 +549,7 @@ describe("Edit Auto Update Config Modal", () => { auto_update_enabled: false, labels_include_any: [], labels_exclude_any: [], + labels_include_all: [], fleet_id: 1, }); }); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts index b8a105f75c5..58d54b34ff6 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts @@ -13,6 +13,7 @@ describe("SoftwareTitleDetailsPage helpers", () => { software_package: { labels_include_any: null, labels_exclude_any: null, + labels_include_all: null, name: "TestPackage.pkg", title_id: 2, version: "1.0.0", @@ -79,6 +80,7 @@ describe("SoftwareTitleDetailsPage helpers", () => { icon_url: "https://example.com/icon.png", labels_exclude_any: null, labels_include_any: null, + labels_include_all: null, }, source: "apps", hosts_count: 10, diff --git a/frontend/pages/SoftwarePage/helpers.tsx b/frontend/pages/SoftwarePage/helpers.tsx index 6d005e1148b..a20c0c37daa 100644 --- a/frontend/pages/SoftwarePage/helpers.tsx +++ b/frontend/pages/SoftwarePage/helpers.tsx @@ -83,6 +83,7 @@ export const getTargetType = ( if (!softwareInstaller) return "All hosts"; return !softwareInstaller.labels_include_any && + !softwareInstaller.labels_include_all && !softwareInstaller.labels_exclude_any ? "All hosts" : "Custom"; @@ -94,9 +95,9 @@ export const getCustomTarget = ( ) => { if (!softwareInstaller) return "labelsIncludeAny"; - return softwareInstaller.labels_include_any - ? "labelsIncludeAny" - : "labelsExcludeAny"; + if (softwareInstaller.labels_include_any) return "labelsIncludeAny"; + if (softwareInstaller.labels_include_all) return "labelsIncludeAll"; + return "labelsExcludeAny"; }; // Used in EditSoftwareModal and PackageForm @@ -106,14 +107,23 @@ export const generateSelectedLabels = ( if ( !softwareInstaller || (!softwareInstaller.labels_include_any && + !softwareInstaller.labels_include_all && !softwareInstaller.labels_exclude_any) ) { return {}; } - const customTypeKey = softwareInstaller.labels_include_any - ? "labels_include_any" - : "labels_exclude_any"; + let customTypeKey: + | "labels_include_any" + | "labels_include_all" + | "labels_exclude_any"; + if (softwareInstaller.labels_include_any) { + customTypeKey = "labels_include_any"; + } else if (softwareInstaller.labels_include_all) { + customTypeKey = "labels_include_all"; + } else { + customTypeKey = "labels_exclude_any"; + } return ( softwareInstaller[customTypeKey]?.reduce>( @@ -145,7 +155,21 @@ export const generateHelpText = ( ); } - // this is the case for labelsExcludeAny + if (customTarget === "labelsIncludeAll") { + return !automaticInstall ? ( + <> + Software will only be available for install on hosts that{" "} + have all of these labels: + + ) : ( + <> + Software will only be installed on hosts that have all of these + labels: + + ); + } + + // labelsExcludeAny return !automaticInstall ? ( <> Software will only be available for install on hosts that{" "} @@ -164,11 +188,19 @@ export const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [ { value: "labelsIncludeAny", label: "Include any", + helpText: "Hosts that have any of the selected labels.", + disabled: false, + }, + { + value: "labelsIncludeAll", + label: "Include all", + helpText: "Hosts that have all of the selected labels.", disabled: false, }, { value: "labelsExcludeAny", label: "Exclude any", + helpText: "Hosts that don't have any of the selected labels.", disabled: false, }, ]; diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index e3542d7217f..ebd60aa310e 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -162,6 +162,7 @@ interface IAddFleetMaintainedAppPostBody { self_service?: boolean; automatic_install?: boolean; labels_include_any?: string[]; + labels_include_all?: string[]; labels_exclude_any?: string[]; categories: string[]; } @@ -175,6 +176,7 @@ export interface IAddAppStoreAppPostBody { // No automatic_install on add Android app automatic_install?: boolean; labels_include_any?: string[]; + labels_include_all?: string[]; labels_exclude_any?: string[]; categories?: SoftwareCategory[]; } @@ -185,6 +187,7 @@ export interface IEditAppStoreAppPostBody { self_service?: boolean; // No automatic_install on edit VPP or android app labels_include_any?: string[]; + labels_include_all?: string[]; labels_exclude_any?: string[]; categories?: SoftwareCategory[]; display_name?: string; @@ -219,6 +222,8 @@ const handleAndroidForm = ( const selectedLabels = listNamesFromSelectedLabels(formData.labelTargets); if (formData.customTarget === "labelsIncludeAny") { body.labels_include_any = selectedLabels; + } else if (formData.customTarget === "labelsIncludeAll") { + body.labels_include_all = selectedLabels; } else { body.labels_exclude_any = selectedLabels; } @@ -250,6 +255,8 @@ const handleVppAppForm = (teamId: number, formData: ISoftwareVppFormData) => { const selectedLabels = listNamesFromSelectedLabels(formData.labelTargets); if (formData.customTarget === "labelsIncludeAny") { body.labels_include_any = selectedLabels; + } else if (formData.customTarget === "labelsIncludeAll") { + body.labels_include_all = selectedLabels; } else { body.labels_exclude_any = selectedLabels; } @@ -299,6 +306,8 @@ const handleEditPackageForm = ( if (data.targetType === "All hosts") { if (orignalPackage.labels_include_any) { formData.append("labels_include_any", ""); + } else if (orignalPackage.labels_include_all) { + formData.append("labels_include_all", ""); } else { formData.append("labels_exclude_any", ""); } @@ -310,6 +319,8 @@ const handleEditPackageForm = ( let labelKey = ""; if (data.customTarget === "labelsIncludeAny") { labelKey = "labels_include_any"; + } else if (data.customTarget === "labelsIncludeAll") { + labelKey = "labels_include_all"; } else { labelKey = "labels_exclude_any"; } @@ -346,12 +357,15 @@ const handleAutoUpdateConfigAppStoreAppForm = ( const selectedLabels = listNamesFromSelectedLabels(formData.labelTargets); if (formData.customTarget === "labelsIncludeAny") { body.labels_include_any = selectedLabels; + } else if (formData.customTarget === "labelsIncludeAll") { + body.labels_include_all = selectedLabels; } else { body.labels_exclude_any = selectedLabels; } } else { body.labels_exclude_any = []; body.labels_include_any = []; + body.labels_include_all = []; } }; @@ -371,12 +385,15 @@ const handleEditAppStoreAppForm = ( const selectedLabels = listNamesFromSelectedLabels(formData.labelTargets); if (formData.customTarget === "labelsIncludeAny") { body.labels_include_any = selectedLabels; + } else if (formData.customTarget === "labelsIncludeAll") { + body.labels_include_all = selectedLabels; } else { body.labels_exclude_any = selectedLabels; } } else { body.labels_exclude_any = []; body.labels_include_any = []; + body.labels_include_all = []; } }; @@ -552,6 +569,8 @@ export default { let labelKey = ""; if (data.customTarget === "labelsIncludeAny") { labelKey = "labels_include_any"; + } else if (data.customTarget === "labelsIncludeAll") { + labelKey = "labels_include_all"; } else { labelKey = "labels_exclude_any"; } @@ -796,6 +815,8 @@ export default { const selectedLabels = listNamesFromSelectedLabels(formData.labelTargets); if (formData.customTarget === "labelsIncludeAny") { body.labels_include_any = selectedLabels; + } else if (formData.customTarget === "labelsIncludeAll") { + body.labels_include_all = selectedLabels; } else { body.labels_exclude_any = selectedLabels; } From 08e64af6aa42ebcc048a9464e622725205014b4c Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:58:00 -0400 Subject: [PATCH 004/141] Create old-it-is-dead.md (#42023) Made small edits for sentence case & the fact there are no comments on Fleet blog posts. Article was structured for LinkedIn. --------- Co-authored-by: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com> --- articles/old-it-is-dead.md | 145 ++++++++++++++++++ .../images/old-IT-is-dead-736x414@2x.png | Bin 0 -> 167600 bytes 2 files changed, 145 insertions(+) create mode 100644 articles/old-it-is-dead.md create mode 100644 website/assets/images/old-IT-is-dead-736x414@2x.png diff --git a/articles/old-it-is-dead.md b/articles/old-it-is-dead.md new file mode 100644 index 00000000000..b5c5442a676 --- /dev/null +++ b/articles/old-it-is-dead.md @@ -0,0 +1,145 @@ +# Old IT is dead β€” GitOps & AI are burying it + +![old-IT-is-dead](../website/assets/images/articles/old-IT-is-dead-736x414@2x.png) + +I was a skeptic. + +For years, "AI in IT" meant chatbots that confidently gave you wrong answers, autocomplete that finished your sentences badly, and a lot of vendor hype that amounted to a fancy search bar. If you tried AI tooling before late 2025 and walked away unimpressed, I completely understand. So did I. + +But something changed around November 2025. These models got *good*. Not incrementally better but genuinely, meaningfully good. And for those of us working with structured, well-documented, open data, the leap forward is almost unfair in the best possible way. + +This is my journey of how I started using AI to manage devices with Fleet and why I think it represents the biggest shift in how IT work gets done in a generation. + +## First, a word about "Old IT" + +You know the drill. A change needs to happen. You schedule a meeting. Then another meeting to review the output from the first meeting. A change advisory board convenes. Someone asks if we've documented the rollback plan, and the honest answer is "not really." The change goes in, something breaks, and you spend three days figuring out what happened because there's no true audit trail, no version history, and no transparency into exactly what changed and when. + +Old IT is slow, opaque, and manual. And for a long time, we accepted it because there wasn't a better way. + +There is now. + +## Why Fleet? Why now? + +Fleet is an open-source device management platform, and it is uniquely positioned for this moment in a way that no other vendor comes close to matching. + +Here's why: + +The source code is open. AI models can read it, reason about it, and work with it. There's no black box and no proprietary API that the model has to guess at. + +There is a single, well-documented API. When you give an AI a coherent, documented surface to work against, the results are dramatically better. Fleet's API isn't a patchwork of legacy endpoints. It's structured, consistent, and readable. + +GitOps is a first-class citizen. Fleet is designed from the ground up to be managed through code: through pull requests, version history, and Infrastructure as Code workflows. That's not a bolt-on feature. It's the architecture. + +No other device management company is as well-positioned to take advantage of this new era of IT. + +## The Intune breach we should all be thinking about + +The [recent Microsoft Intune breach](https://krebsonsecurity.com/2026/03/iran-backed-hackers-claim-wiper-attack-on-medtech-firm-stryker/) is a case study in what happens when your management plane is a GUI that any compromised account can click through. + +Think about what Infrastructure-as-Code, and the products built natively around it, can actually give you: a read-only UI. Changes can only come through code, through pull requests, through a reviewable, auditable process. There is no "log in and click deploy" path for a bad actor, or an honest mistake, to exploit. + +If the Intune environment that was breached had been managed through Infrastructure as Code, the blast radius of that breach would have been fundamentally different. The attack surface shrinks dramatically when the UI can show you the state but cannot change it. + +This isn't hypothetical security theater. It's a structural property of the GitOps model. And it matters, in this moment. + +## How the journey started: small, specific, concrete + +I didn't start by asking AI to solve everything. + +I started small. + +*"Make sure all my workstations are running the latest version of Mozilla Firefox."* + +That's it. A simple, finite, verifiable task. The AI looked at [Fleet's GitOps documentation](https://fleetdm.com/docs/configuration/yaml-files), understood the desired state model, and produced a policy that I could review, commit, and ship. It worked exactly as expected. + +From there, the requests got a little more abstract. *"Make Slack and Zoom available during the setup experience and self-service."* *"Make sure screen lock activates after fifteen minutes of inactivity."* Each time, correct output, correctly structured, ready to review. It even created configuration profiles without me having to use iMazing or Apple Configurator. + +But here's where it started to surprise me. When I asked it to deploy Mozilla Firefox, I was thinking about the *deployment*. The AI was thinking further ahead than I was originally. It detected that the installer was x86 and automatically created a label so the software would be scoped only to compatible devices. I hadn't asked for that. I hadn't even thought about it. And in the old world, I probably wouldn't have until an IT ticket landed in my team's queue from a confused user on an incompatible machine. The AI caught the edge case before it became someone's problem. That shift from "AI does what I ask" to "AI thinks about what I actually need" is when I realized this was something different. + +## Ready for production + +We started asking bigger questions. Not "configure this specific setting" but: + +*"Apply industry best practice security controls to my workstations."* + +*"Make my devices ready for ISO 27001 compliance."* ([Link to pull request](https://github.com/fleetdm/fleet/pull/40958)) + +And it delivered. Not just a list of things to configure, but a complete picture. The pull request included what controls we already had in place, called out what was missing, and explained why each addition mattered. Structured, coherent, policy-ready output mapped to real compliance frameworks, grounded in Fleet's actual configuration model, and ready for human review. + +You can see every experiment we've been running, including how we are using AI to keep documentation up to date in our public repo: [fleetdm/fleet on GitHub](https://github.com/fleetdm/fleet/issues?q=is%3Apr%20author%3Aapp%2Fkilo-code-bot%20state%3Aclosed) + +## A pleasant surprise: naming conventions + +Anyone who's worked in IT for more than a few years has opinions about naming conventions. Strong opinions. Perhaps, at times, unreasonably strong opinions. + +(I include myself in this. Fully. No apologies.) + +What caught me off guard was that when the AI generated policies, labels, and configuration files, the output matched my naming conventions. My commenting style. My structural preferences. It wasn't generic boilerplate. It read like something I had written. + +I don't know whether to attribute this to the AI being genuinely good at picking up on patterns or to the fact that good conventions in a well-documented open-source project tend to converge on something sensible. Either way, it was this small detail that made me think, "Okay, this is actually going to work." + +## Human in the loop: this is not "AI just deploys things" + +I want to be direct about something, because I think it's the most important part of this post for anyone who manages real devices for real people. + +Nothing ships without a human reviewing it. + +**This is GitOps:** Every change is a pull request. Every pull request is a diff you can read, a history you can audit, and a commit you can revert. Every change is documented. The AI proposes. A human reviews. The human approves or rejects. Only then does anything change in your environment. + +This is also why [GitOps training](https://fleetdm.com/gitops-workshop) matters. The workflow is the safety layer. If you haven't invested in understanding pull requests, branching strategies, and rollback procedures, now is the time because that understanding is what makes AI-assisted management safe, not just fast. + +The human is not a rubber stamp. The human is the point. + +## The tool that made this click for me: Kilo Code + +I've confirmed this workflow with two AI setups - Kilo Code and Claude - directly. It should work with any AI you're already invested in β€” OpenAI, Gemini, local models, whatever your organization has standardized on. + +Fleet's open architecture means the AI just needs to be able to read and reason about well-structured text, which all the capable modern models can do. + +But I want to specifically call out why Kilo Code resonated with me: I never opened a text editor. I never opened an IDE. I never typed a `git` command. + +I chatted with Slack. That's it. I described what I wanted in plain English, got back a diff to review, approved it, and it was done. For IT practitioners who are interested in GitOps but intimidated by the toolchain, this is the on-ramp. The barrier to entry is collapsing. + +## This is a superpower, not a pink slip + +I know what some of you are thinking. I've thought of it too. + +*"If AI can do my job, what do I do?"* + +Here's my honest answer: AI can handle repetitive, structured, and lookup-based work. It is genuinely, impressively good at that work now. And that should free you, not threaten you. + +The engineering work that requires human creativity, judgment, relationship, and context is not going anywhere: + +- Understanding your organization's actual risk tolerance +- Navigating a complex stakeholder conversation about a security tradeoff +- Knowing that the policy that's technically correct will cause a rebellion in the engineering org if you ship it on a Friday + +Building the trust that makes your IT and security programs actually work. AI cannot do any of that. + +You can. + +What AI can do is handle the eighty percent of your backlog that is well-defined, documented, and implementable, so you can spend your time and energy on the twenty percent that actually requires you. + +That's not a threat. That's a gift, and you should exploit it. + +## The future is open source + +The through-line in all of this is openness. Open source platform. Open API. Open configuration manifests. Open workflows that any auditor, any colleague, any future team member can read and understand. + +Old IT was closed, opaque, and tribal β€” knowledge locked in the heads of whoever happened to have been there longest. New IT is transparent, reviewable, and collaborative. + +Fleet is built for new IT. AI just made new IT available to everyone. + +If you dismissed AI tooling before late 2025, I'd genuinely encourage you to try again. The models are different now. And when you point a good AI model at a well-documented, open-source solution, what comes out looks a lot like the future of device management. + +--- + +*Tested and confirmed working with Kilo Code and Claude. Architecturally compatible with any AI investment your organization has already made. The open source future is already here β€” you can see it and experience is yourself, right now.* + + + + + + + + diff --git a/website/assets/images/old-IT-is-dead-736x414@2x.png b/website/assets/images/old-IT-is-dead-736x414@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..a472c69bd036cdb6f237a8fdeaad02c1af43c090 GIT binary patch literal 167600 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ℑ&+XW=(48Wf{p6Ax_A=YQxpON@SvL4boQ43 zcFHO;_!{8WsF|XOfL%nNj?atgT0}Mc{F+YD4}%H|;eNQ|7|?4E z;noMGZvqG4533R3k|nJr;Q%;4V}9`VI|lgiGp<=kJ1f6ocb;$>GV|^iv-ewm_Y9on z#;?#UOXOwn-fwEz(mD3drv$(F%S!@J5}u1P&3a9EiNkS1c1roH?W`Dk3QaoR13ZP< zxqnqmE6u5qN0L4$2!5{=zp0eSl&*=}FBSJMoZgI*c2?UY#A^e%RNu&BWY61BfOrh7 zR7_)b1V}@0B02lWf{Qrs0G$oG)#uv%`-;h9+!M_{^o*9%o22l zD|eiJGvN|{L3s|$W3+pVTk7pqOcf6KaW&3k;_E<%+6*!^oJ??%?p(ZvdE#LG(5SGO z%$aWBFd6{gZ)b((%1NA@D7XUw-HUk!dM1K;`) z@z;SJ2txJu`&Q9~ZIgfL|KWsJnDDf*7oR=-%XBhRsRG+rh_fC~9>6v$S57scBsJGh;cLDs(+uPqH;6olC*EaAaOMV0Jk0_zMtBr*3 zY$^D5nEoOdU(DZLEP>jYO!fVCR`Sn6j4^~pu`3s1`jn2*trvJ7<-%bqgLmp7@LKCU zRK3M$=Fu1Q>M_FghdZ=O=Yz@=s`u?BaWKzPfj~IbmcH4<4lF+S;*yS$?I~5!PL584 z@alcSd{l}pQg6@6lutXWBPZ@p&+V*qD?NKoG$J>>qEZrT&c=H?D?I}Et%~6NCI?qg z7QmM*L0SebfafFh)XjzGi2>5t%dcGDTsrH=nC*gz_7} zkXqobor6{LW*WiZ7)@dr+jJCN?|eIEz7c0AK3~LBAeVvj`rApZ=jwPFy!iy^06eu3 zFrJ-*wdPP|Z~+>ClXsJ6YaEXva9f$Td^$d_^jioA^M{*>%&LjwN&us!;XLR$eV^ir z8W9TQZ{E%&@W%!4B}*oN`&Z2EY90W7WFz4yqofJSHGn5j|9hBju328ZK_S5I3- z5>C1t0m=DEGwUgoIOi@(1JSgzQnx1&i%D|T_*)O4d^dN%YeLxkQj)Vhm26zzNx}t>9cx^uG46Lv{%Vqw7b0aS{ zW-8o6=qW3R%cl=KXN?ALC3KwGTM$j+qIGX)#Ul@&=*DtI9So@PwsuwyO=@5qg;3gA zWy?t@j1#jpkOji5rHeOF$A>jsRVQZ`;eq$0SuvKkc2@eFDltF1oz?iboz)n6o0CGe zX&sd3^kuYHvra0rLiSJtf3*_uA)KRE2L8v_P!0){c<%2p+f4po2rvwg?`^7pj z0k?I+#7zB361iN;n!`DQIrsoqgGAL1BwX>?VKwT5WuFWdj${fm3>^Z&Ws?B~M zCfLmkwXq~I8D%mi7MM?MspKyT=cz&ASPlo+EgiS&wWtmj?wxM?O5^e|rS9TGnG{hp z;Xj=PZHnW*5mP+hdRPJRP1T!f2ryU73vdEx;%Prfz>lKJz^j?;bp>Sse94lX3><=g z@Z6GcwnzND!}f(9;^{9Dg7m1#v1{~IFE@Hso4u|HftI>R}_<~o8K^d%!W1# z^cG;5l6V91M4WLGoZDG>?#xC@5F$4r?uJ&i%4&^6!f_NYXKna>z~OBswsV6Ihv)6l z$RT=;@1P*$<)^{&sh!pKBd6P0>CNFwXlzI$o#l4rdU{V{d`CW=WN);Ma6D-xTP5%X z@Fh!TfQ#Q-4TXLvA9^GBE8J}?(x&hC{_#r3%Mm@wuJj}Hb%k0)E&=#;QF&NGaZb?7 zRq!Uct`;e|Y+lbs_1fpHH}}m;(w5M)Zn<`Gd@gx=OgsT>jH&HOSs1K@;!{^ETnv1$ zOlVJKO7RbtypA2@hwvgZt9v`E1N#JJ^JAv$F`kF1RQz~WL1kEj*f+v9^ft#+?zS{N z&bEKB?!l~5xI4+_I!r1kD+6D$e6-zhRyOY+7XRKf6~q> zRq~nS&|4fv_ua)|I&}|N?#hYY)Jgp7WD4ODt*u>z(u=zMIpMxFt_p$`(r8C7s^Dq3 zM?r#YXX3S7))<25rJPm_W=k6ydHrhGMfs+`+K{?s8Cs}$v;)G@L| z?$deu9N2|2-avdnc#Lw`RarHbZE|hqj(Z~BMVLk?^%LgGz?Ur91GqBqFUK|%w?k1o ztJehZOQBlQext-PjcY<-PAta$6#V080=+|SlIhmYN>UV7fLR{4f}8jx((*~c(p%)k zj6*}eq#mQC*A3m7(SvXdIE^Jkr!qATY-!~A%tgLqQSpL0F8x)anM}ttXxPL zYT8+ys-0D}DwON2)ASOGCm&DktS~S!QvH?z!*NLhcxT`m-n^Z55jp{1yHJ)anE~!! zG1-Q~MsRw97qU~P_E^-e0Cs2ae!V3NTp{Ua<-1CZMpZJZiAga$KUuOU?W{PR!%Cv5 zR!bcVpgk%>n!~<=9e;2wM%itRGxuXa{S!2|9njoEg@CpUgrU89nyrR44G$c?7GS)tKE6JR(DIu8K&yp~TVaD9xM5 zkW^5nV?6&Mz<+u9^5qZzGLnCI`TdW7ZRyuP{Pa@)fuCP2`IWshYjLKC!th8$MIj=H zE4ZN-Zit8y7sf!ETm&Xpfm!JP|2yorpZCmbS+>S~gd8VbXQ@sy-^DY>(^aP~@~1Lz zrQpiI^$_#jw6mJ{{RQM+X!%{9wjka^8Xsz|UoJu(a+5Vnd9k6n2Gn?~0hPRx?Aei` z5gVgwi3sk>ch-ADaEwUSgxjd?J-##TtkMug2{=3HyN&c` z0(_k0&iYQq=HCGLaYX9VZ^Y!Y(&{_SWcEKLJAJ!d^${eCNzMc#~?dO;jzikKX@j>cf9Y@>Fijl26 zO$Pq)AI`wV!ee1d#`Zjb|F!zY-W0A+AElg?QxCpt|3|5OcG>0nq5u0Z;PU@0SRQah zjukmPbCD-06Tl~eQ$zXSJvz#MJvT$iKFoIYx2q}W3}}q~LG+B$xJEh$>j<-vC)&zE zx3kLZ$uhRnWRN+Pal2e@QBXHDjlC<|w%g;g=(aIJa*VXIV#L==+8Y|uj5ti>3Mt{n zlkB|RdKspJVu)R9z@>oYw6mfDzTD30g*t3Ui%%30>?fUvmx8v`^<-$4u>8CMZfT3) zOI{-BW2M0+@ee71I{~j%;QuMWHy?d8{BeM9#KVM_H}aUs!LxL%0Np~I2!fsv@|zvOvkp8i(HDib zRn>pcv&3iYs&QAMop#~#J=v7g9AYdtF4@ImE@gzxz6R(F*10hfIun4psl}8h#6}Ca zQSckUHF_4lRJ$=Db>J@~hubNel*Sd-PgA`9o6SC=_FB6#70|s)jjDuc^6zF34+qiL zk!tzLIzyRwRRe#eZ6;eA>Hi~ukADK-`sh3GdR7G`Q_q(Bb8kDOLX(_4E{Z+-?ct%m zdS+3Z-|KqXJ#&#KD9XS;Q%Bjae%{XN(@CEFa!SF!tYw7Zu~a-byBaq$`f_=~9lxt^ zjVnHXp13@>NpU+X;0)2#ZWe^+;UxFN_~mw1=`b6Hr$T~5R^QIb4xE`AajX{~<&YVs zD*~v?X1=SJQ9|74Zp>1 z`^!qkjSKa0J7#;H8Y%dH3h?1PfdAPt`u0zjzsK_czSF!8;DcTD&Bs!1+pk6$Xmcr| zJXsOI+jds(e$6Tr(R4bKC+i0(gU4nB>KFlYMeL4hs~2&)0+n0K^T=Ah5VVHU#yC(_ z!1or?xiCM+J|vwc=I&P^dsJPMXgd_Vdt_FR?vzu2erVhmwNaS@kWpTeUaU>f9^Zz@ zCH4mCWY=zIHR;kwznyujB+lE+>I<|Uq{}SBS~pZXD?@&Arnnt~_r1U~34hLckgJgW zn7pIC5`gv_$pTtu?AGXT~U!4K|^O&7@4x6r0e%fvV@cK%} zN7rp-;3GUN4VGnrPUI}+fH2t=&7tA zW-~4tpVaCT_*}SlVLVI2bt}NfJ=q|(SylpZyLMKAG}h{O+F2nrGgdI3CRZ@o(l8wi z=bjWn(5*5y88i~Z+9&35XP^OfAK2Q4~(blsWL2mWCq_qW5APOpM{6riLBbkmHm0gWZCArbXmZcRpZH}74_;8DbrQREQV+A&#$(# zN--t)4a!?`_wB6kWQfG41l(HTS}YHa`*v225CrvDvjoqKw6ltfYpqBjp9bz#8JUCC z&guo(SES#N+iO%_SSbl#vysRAB@vIwh@e}g0G_nG=`m}^D@wr)@M>i>$zLp?1aYVF zmYPCfy_W|UaN8fZcVl^O_RODT<81YG+-v~r*#NiPL*%@H-v2?`!+t+LJ~SNnk09Qi zSQ;{d+pzWVw*5_hld*l==&8HZ*;7Ed-o&-%H|+i>ZTQalXS~SM6Rkp-GVmYgLwP>n zVB^PCD8ekgz=fFDf?Bn^{ zGH_PO?H&qU13}03;hn&3{({`0kj5RUJG1~FjP(c*5I?#=37uUlp=Fh)_*e#cJ%Voow!mGT3!4?( z7?9jX&>h12XStnK1Yfuu0|i(&5N^-u!DBaivT4Q?RXF{UpmefrNp1t&_Q~xki2}2x zynN;3G3VY@VmdKh*OTix`DiEG^4kL0v7K?FCjS<`F}RU+rEhHSQ&F3Pq&JYv6qzx0aAwd z;ZWu_rUhkUnu*68mnCuT-SV0+gqGTtvt`$nJGs3tk=xPB!sg4S*6k@C(Yq@c_C2YT zFs5M@Giz?!&Z?PDu1)xg(X@u(s+wT6UhijL%&0IpRbb?TWJUJzeii4rvuNAUh7 zTD-+yImP9a!)16uH#!t&{fRkVJLV~e=su{Lc2*!>8pww4lPJz?Zt{O^8N49h^4msm zjt{kmH#YYJR3-=JbPl%?vdi|%F~hh7O@U~IX;jX0xa!(jz0kC?aoxllhC%=G1q|j+X!qwKMyE5#Zc+WW+_i=5&0Z#&iMr|4Vp?S?dS|@OR(;`co|= zTybh;(=ayRj`T~tT{(~RV7K-XV<)U3d^|LbgE1+0lZ7irn4FtQCbC@Ku33OD!b9{~ z*bbCpcG7J+$#Pp;8xOc6G0tgc)rz6p=ih)l8S|uMbqQnHbu)l=9E9f;t}~7No4Z(c zqLE}Aa!=#raXYK^S`~0T;*@AB{aNj-vW*Aa%i<-M5Z<-3nnVe>5Kb$UPyWUd%FqM+ zx&=7v3>N`D{5HTJHnZF>1AO>hfNz>#0>1W8&g6Vn>t3p64->5QC1>d0>AMatpv#ku zJT2odo9UmbOaM;_#YggRFg?pq>hK-LN!mx2$YMLI7?=&lfBj5j^B1_|u!z`%+ICiQ zQx10n6J7dtR$!T-nubGViH4}DjvML3iH6Vtw)IHo@khD74SGZDp6SHf<>9~H&dLfZ zSJbAR6}q&E#i&RW5Nmt0@4__g}U&a4o(zc8H{vgSJ|&xN3(o`({KFR;FE zX9cH)WlO3m_??YMEh1+zO3EFY!PFI(DGrd0_cB87D!(-k**OLdjSNxK&WdSieR7qK;xfEa+)payQKn6=Dp$R)WN@e3S*@m(C+kU- zscbx(!p*QYt5W(9L%-b;&EchXR!muFXC-sqEnmID@xMUrn^TLK2k6mB!`;q` z$s)XWW$H;LBwck_RNdDFX%GT5wd@8#=Gxjfe?K{euLw?sCGjYUjYNmT|rahyt#Tl8DhIsO3d}IOOFFK(W&v^8JSS} zEPYsOwk{cAZIiDxWHVz<%Pfxjo%vtPh#!Nb$Q;Znr+M)E&tO12(9HzPo$lB`B4St9 zqW1Rc|G^~&xQW{_uJzAeoNHdlhv{FZ>`D&AACoLt!YvW~E{Qn0}{pX4`l+d6Iq zcYNV0Wzi)k=tBIkozc5-AP4V+=aNDrI;5ba|J3rc@%?GY=^wG{Pku=R^M3>CTWDkd z8snT^Im2a%o|sXNSMnnE9n%Z^TK@)M^i|7$`?~Az6U_g4^l@egd*! z{vRpD&r^>tL-|sg+8@yVA(pUN>4&qmmd$LqiwKJTF~#x9d1LL9?d}kb#3b1LCGb3C z>PzMBOjRRqzs$t4e!idN2H7aw634m*E(BvEuO8)Sw|+fagvMmxr<{Z@3om9;GV@`G zH5Ex8Z`hx#J`Gh8D@$7M+C6tW9BH-#c@^~l)Y`V-6+eE<+kC4Uc%n#dzk*~r!@WWR zMGuFS^7b*1BeQA+wBjfb=23bq@m)6y>by`(L0fMdpWxPy6n8^Cu*Us<-UL}R=c{1` z+V*@~83YCi#x;X_X6&S&f3s%dh&+<#5_E!j;b2GaxS#pkQQzKvdVXposLSF}^e8i=$kWn-@ zG1nnV<^1dZWbpAYM+K$3V~ky=L?Yp~2q%;-G`}Ces#52D+CrN5&VA=O_>8&!Vl)VPNjNqJJ zHDRg}`&arT(Vv=MCNdn0STKqj3hAnpsPs-@26Pl3e$s5#<7gz4slL{DtM~4KDkJ(2 zi#EG}^`JIp&09g<+&d{o`qK?n{8-Y4jJQP=l^32Z$^%qd6}eo@kg4?m zB+Lp`s?h6oHiKwCION&`R4$vmdUD^&?fiQ7%@3AF{E;fchjX)_+q<;mHr%<)ueIT= zZI72T#{u!hmVx$5yOpln$D@Y|gOtz}ezV8@wC?oQ{8sZxw9%Hq+fcJTJejcv`lq+D zcl3JnkBHm5O#is2v$h?x1m%@GZu$fJ8_4gRWZCT}HTVbmoQTQyI zvitWn$Gy-e6+@jn&x4Gs-n|Nn>@=P>c^%y01t__*O7Qtc zOz)r%r?AtNg=fC79|Y~jDr;#~J~A$q+UIZ5rhMe>>AKg4a+1Q+>~Fuu#y?B_q}Lou z(qNm+4I&J6i#Po7+n#P%^mwJFGenB=6x#14^W0UB=SY$bspDDv?PdH-Xl6gvQ}ATY zg$k2jqV!?`G-WAXnubqHlS)Xy=if&?bq}-5xtzuY>Xz@zRNIMl+E|MH%YPUb<@Hc< zgfcw9e@tg+k*9HqCi#xJ?ABf)IUY1fo9PvsW>kOph#u;4omRLLYIF7mj^~8j?&!%Y z@1ir>dD?kSY9!1$V>qWPC=_OO_y$qtyKoPRKCA8y9>-6F&46 zN6>TM9_eHG4AAaLDhI5=ChU-uKpfNwl;&>C{cmBpN$Fb`QgazQ(BDK62hU5rXmV!k zo~`=*2~2>`+vQ_A;Y&@=78uFk^uB1)ks32*u}YD#7O(z;bu%qLXAh^=T{UB{+9bnm z(xL;2^Z?uKY@CI)R9+_$eG>FzD~Y%$0iNk1=Y@m`Gbt%2HcE+sCSBAwjsNH*Bt4%5 zlY+{hxj*>zgV#=IOS`g16Kk-MR1@H5Uzs7BzRhXyPD^$7vSBC4OM$S@C23Gy3C`Ch zHFX66Lv|}F?2y8M)rXKSE&pV=U`3dx)v!xMwKl~GVe4pqwN~a-gw(MU%8ETag?p;? zW}VrA*`D-f31YVOHm6}>Q`$H(gHhZ|5I!99VtbSrZL+j?t93)UKH@TWDEZ9| zT?yVwp%W{ZXM9eCRa_cN8lhl^f7NNZK6oXr`I7G1jRhWU4Ll%+r6Ce4j>uvLJ6;l> zyZvY^t{*I7YotlmAtzldhbZHn)g)QBXD2}}fj?0*9=B7;W>DN};7Mh5C0QB7p3&R| zta{l|uVJ2yh#Jjd{UVEUDomsEzNaJ>0rMym^e3U6haK2Y$6z)KZ3(eNIHO6HaYUF} zd?FpR`&QirWb)0kJ=@j-yRsMcQHZ5LJ5cHc9m=Nz3YXia5f=(6xwTn8tmU zjo>2z=@2+$-*O(j!JH`x#f){76sscBq% zkBB1KH60#wtqQ7(NW>3o^mK)fJeh5g4U)TSnZ75Cz@uF@L1G8+TZw09FH3h}8Rb%V zl?>~jnLJj+^TeLm3UELDoe&+ydHs76wn&dPr|41f9ZFg(hEtICIBYj4zi-S z{-$rmvqY;KpB?5xL7#gIPEz(4iOw+VA1R6KDBqmYJ}s0SgJR?c2WaSOj9U=os(e~= z%%SYOewFdu)c4iJYv(`JaBhv9jHu9U$}_=3C5=N4sFauzqO755&7uVf3-_qeX_i$i ztnF`6Lg20?V{A^$|H+2tigk}Tc`v0P2aC|tHn@FQtftIt4{-he!=#E*qQJ(TkTxbz zry`;Mbc$4QT+qjZJ6#NKMg0L9W}?^)h^-027EyFGc=yr2q@rDQIZEU$SSug;F-;lV z!dZ2R#ZRJNCL=ruwu~wG;UW0P(Y&Nzi4aaE-kNQ%g;T?wJ}$#aY_nYwmL%M`Cax%2 zk<@D^>*?55A17GjvaCtW6wdoMBHho-ONzEqw0VU>n~a{N{~K+L$Q#6-vp2Jsxoc{U zLwH>sE`UqFWWQz5-M9_OoG5B|qVAM(E;?b&{N?UV>{AKC(0LJ_yC)LtkD$z%3%6qU zK~GRix{9@7G0&lC-hrPHiyXAs+`JLW5jkEdT9SRV)TiYbpu zrZrrjU3h@F(}xS{GJaZ{7->(gTIF3p`PeEJy9}IFYwI@SDSOH zSq4=Vt1*Q>-saevd}KvL)tuG7A;+;IUe5w{0|~r@=E(|)(|B&cTa`qo1Fh{I8BHH# zbRdN<hF>CV>Et zLX~k+kF37-1AuZKH^B`$=%as$n%-`G{k#S;diNH@Etv|(y*O1j>2KnLOo{cwl{Kbj z`1%G0u~yW!7+#Dxg-~wHo0sMt@In;XjVnlMbkT3$ZhWTij3(;YZ(<`^6>beXz%DQ# zd&qkiL(d!RBn{wC6JvWqQejwUWx6_nlz&mkOF`lb^j~U=(co047k)`G5L^8aUMEL# z$$B{%$u1gYPx>DEkf~HSljL?u5j#W6yFD#%E)A=>$qf72s#P5&*5c%1Z;f^VR5z!c z+!TY0ITO$Z_klUqddtaDT%!(17C46MsZ?%6Q@Zy1c8jZYNbH(9iI>al_1mC&kK9}QO%kEg$ zCQka~NSKWTbD}k@^%cPsGo;4AK{Q{m4oWKInEd8J(p4`Cc#oxNJxfgG!7zfzPVFo4 z$Dxy6>7{3ULL`fF^`F$sQUza0_k#;DOofPrP=jpp-XI3OL~mXc^_!4eFe!KsvG0Y{ zvR!7nv2XpMD|gka!6^)L_sj`)0uk}C7^t3f>2H^BUpqHg*qiIhzI6z(q$wn~ zaYH$enpSatkKM$cTJsbYP9gWciMl{}XNC8hnp#ZUYnP`<9QylV1@9Q$k*-yOF?a-? zpZ7n+A&famYDnL3{gSssRE}qm9r(_H6G$@r2kEmboYQY?(#4tVBhO2$3hJ1s*6j&q zK7zn{JG+;m4bPVPiCIrhpNSM?q?zlltG(*J9&2nutxy`zQj1Q9n1} z(j;>ge@#om;K(b_J)B?==h&xG;rK0rOG8ZQ(-9j^Ln8dy6q&5cy&vUsOSDW3tbeY-z!uD?RLjf1W^4^ z6s0(_>VLe*kmH#+Z!)Cey@O5O?}WdF-m^5!9}j;0qGzOqT;wSCBE^yz8{*U8>9}O3 zNn-t|egZn@>;4v49X^K4?*(DwzKde%;Q@;VdRbSc?N~0!Nb}u93yu9wWVO1mM0=O| zMg&%`D&k`z6a^&16#i09AZ0hVf9xXXQlg8wX#b=1-s*&@R`EXvM(&g`U?ZF}zM<~N ztc-+uC`K0N492C$tT<7v230%@xcXWDG6nLlTi<*2bwQn=mJ5<{q;xj+nsMSL>oO4iNvE*CK|wHKo|nQ)9M#FXWkT0> zIhx7YEjF<7WxzN3f*XRKvHZOriwupQ9yoce?_<+pUingYnl||~X2v;fm;AH0a|696 zBXk7&vEyRgpt*)eki)ct!hkZ_j5lEzmN0ur8i%xF(1IFY8e+d=R%(8#+f6XfPyjDk zpvY}RY91b?9mYTRvSM&O&+vPrwqJE1KOW`U*jtHD?ixspnUFxdB$`c_vYmSfZ=ai$ z=n&ho)R%zu4_op#!pGLJ)$Pknj7|CrMj*8W*oay$e3`Skoc+h2WGgq%92}03pK7D> zjUV}WJBxGt*9N{gU4?WVw`qsa=eArP&)xQnS!GG1*On0NF^rzm^YSKd=S*bx9@pYN zkfiVF4(fWps9m?q$j0`X@?|91XfEs!cEKx*93KGJG~2IL=R2~JEPqVrj%lK0F_-+x z&XdW#R5O@=Hubilpj#o01@GO0Uj_-eD{U_NtMiy9!Y(h7ZrY7X74pMPc^GHB~0v zGwJ7!o%q0gNwpozN9LDAbES1NU$u)W}U;mq6Zwy>5fR};pwYG_~L(s zuK0&LKg9C2B$X)cvJRpwVBh&|YuAmdVWdV~r=dz^IE&AU9Dk8eSPjXKD(B_sqbnkL zC$qNRy=M=@1;mUWwD{%8;<>)3h0k1t{@LPbb$f0RY6Al6{86Z6pmtu6lOuy2cY0cW zx_7jKe@irP)cd+Ug!i6gsf*cG2{Fh6sZ^Qg07}jXTR_S|0zArwST%NhTkme++9&yG zcc|_OZDF1xOWN7akb_<)1aB48&n#o`&NCU#mFf2tN;&{A50qHqlWSK!ng$C!V$f_? zo);AlQyH6m0_TNHk`<3&njaOhyyIC5=@~bbjOK4Ph=-jbVo+cr463{GaKu5U_YUco z3|~KQ0P&`oaMd1)=Q1l3`|096QnU8GTIhyV8C3?{GygiL-v0RoNAWz=u5IWNW#g2@ zo6m$y^Twp@lguSDDIqChrCQ68;H2yFVHvx1?jK?it@2%r*+h!1I-$SmQTI=ES3(eX zZ$G%gQ0Czc6J2$~k^;^uFGPg+g93VFs3DpYeb{vp>rpxfn7Umufy>%7H^)uMK7q6V)0CF*+x`BOlg`3f)*AJ!gJe6F^h8-*008( z86h=F2rl2%F^1l|gq-h$w%>VeO20i){t15>1edaLXm4E|IY#ofFg@PbF*g z{FFG5=g_LZf~B2;bwY|G*2!+lpi})H*KfeW*7;w%lUIZ? zw3-A-7T-zrga4rKIz{kJ<8PMrev-&`*&hnMe^xOwaPmk{=_91_y4kbPFtF&tm>-hD zsXdI|dnLcuZlq9TU~nz+fekzNhs7proi>)V%_~~D4{?@Xd&l+=xVsfamLDy6S>3yC zIsTm9vK`<119kx8LWlGTdlts(bsOQsMa4e^;=fuFLW%P4<~k{T@!Rd$Gv{L_7`cMy z4~h{fZ-VU9oMZ4JwaBG^1#}8Vsn9s>Dnh91<<+TFZtR}6lgGX)^!5~SO?lEH%p)fl z22Dw<6<6Dc+(Xf$J7M)qUZLY&`y=VG8-g+X>X~&A%!QnA)EvY3O%cfduUT%1%?S`pt{QIf{^-UFCA(ul-<;HCzl=MTT8njh6@q??% z@R!>1qX*l8BS&67X)Z4JeUi*+logg~1Ac)V{?kCxh8Ac_uPVN)9(on^Ab6><4ytQ; zcp2$c#iV}nqgmcx)c?GwcdOz;FssL2A-~A8&wp7FfeSrOfRF1Ox4bn4k>`G=r8z?x z-b?c)qbDqsXGK4YZ*CzN$@zhzN!*$aAV&=gsCp2`qo?nFo&0gY+M^oAj`OFP z`;tc;eK=3Cy-tNky;bEs*ve3*vuFP#_EPUAEJaVOTAf&t$WnxR4?AuXmd>0hC&k0s z+lL7%mk0>r2bB+3Du)s`k~wX0C}5qlG+|hl_F-7FrN?aN)J+e=^VEK6m?0+DeLpt6 zN2pFN8zF4j*#>cy`<4Rv|3<&E`zyWEf~Aj+U4lL1ZO`Z&d4QJ$(6A3 zo+@W=J#!#%Eb9ZbpkwKOUvb*Wc}WP)pj(SEQZq|yr)5ym1_0)!*I3d9Xi|%bv_E(q zE4TqCe6mS}%N#J<>lt#4pxmf)pGa=&WBYA8e!kf>kTA>f>)70UM7c+zV=dIa zbi87S!Ch?9(qc}g5%*nnOOi+J@kNKxMtsxTg!T8Dl~*PK2bZ5U|kv>Ia`be0SC3mUZo=J$pXKvGd0&Y0Y*6yLfU>O8Do~m$?M^ z54GYK?}PrfeklXLa`9E!`E~ceq5?+WR+B#FtVC5AY7)zs5LK|xga8Achk(g zPG9=L?{$Wn*+}cpezmzJKeemmSmz55Wv7cts|IbGzY&4aRjt>eJY1s$2KraeSrHD; zMpU6Z)cUOs$>rQGDZM~Hjz9F9lHUU21v950W&lJN zheI+jQ8zhv+Yc5%vE>~Z%>pWLwC1@6bnaE!XsoNA;zxFKqKs)~^g0E)-F4QtVD-ROcXn5v*aP%J)d{uyj$WHGc zD0>Dd0||N|)V(MmSG`Na6gcjjkVrUAu%~C>XuYD{m}EtAPvzd+yXa41oi#6TfnOP_ z!t$<3(Pnz>)jZ=2x9(;QK(xPliptehygH1_TXIhIv0*)t4|J{JDfWmu6Ok2ZGDVan zfPV%3f$~6VfE+hr(W=jD#V{1Dku&7zJUvZ$5_|tf(|bTFg0Q;A^fTh*^+_S7Y;r0o zdRs18RJ^1xh`@qhLj#F&(lxd_&h$?UMTzdpqV%_>A^qdo-_Fz~dm%66-UNT`KVv?s z_Bys3E5aB?tIHe-jz!fKd(@lK%+(4^!i2}N_zq$PF)5ws<|e6w%a4@YadjEfv{R+3dWEZ3Y|i9IxLq!Bu0P(FD6&c7z{N*>S-;^+;z(+rOG|fu@2h{qSr=yW z>}3rf%oF*XwI&B-&6)ODs!6+g{8h&0yi$paVG{goH%AV^b>BXxN#Qmqo%|DB!SM{^_I#(+=$cc{Nse;x~q}`~`wQAsk!cclM44erg~OYM7iF62;Xt<+IdH~owV&@CADNV13`gO6Md+%>=+-QUu&wWB z>>?H+UdGz$yf$vrmjKCXWTCNyZDkf@Y3H?ZK`aTd5NDWFX!cUi`CawComBF)q$Hm| z85V%dAio!OBPw3IuZ&(*xvN+JbjUz)e4mRXqqOZ2Xif4OX=1J30;)QD9TTK5gN&Vg1bJjTN=SXdu>2#@=4nm?hfHZFa#s zUjN1#Wn}iI4@}c|x#3(7E#Q7pFU09ei;}b+_37dGzTGL?mwHX8#~l1x<_Hr>C~&;o z{{~_EA#oE4DR!2B_eSN8jq(Anh=!M~Q4hhj-S{4qKu8S8sAJ-hBOQmzusIu`c{b{M7+ezNG|| z$Sn5yY_|GHqUBea$+;@}In39~;b)UN6%l72kQVYrC&--p}RQXEqB2}F zZf$E{JBtG>a7;#pk`7dEJ$R@4t@-bs@Awtd`3eE$TEvz4UH!!SoN3eBwpHU{1jA!k29hv$n&@*{S%df0>4J+R#xuFMG4>XG9h0E8LNxN1!AgM8mJR3Ir;gO1kA3vP-BdV%)GJbA_4OumOL3G)Z(Ywf zvxK6~BvDnB|J3#LcOT16hiI5T;8||cu{~JIa6)>b-IAbFQz-UQs8fAf#(_g#KPh<) z41Tdz^e?>RsrF#a_};1;Lf!pgJ$Xq^!f$RZKMfu_UGL|rcVG_c{(=A&qy1>-d0vwl zAF;00Cofim_loMFhm2Cdm@c5p(|KbJ7`-jH)Ko?6X}Rj{kkZBBZ}fv}Fx>8WQoLWq z+EJ1SWND(q=Ap{J!fb!ISB{G*3`gNUTtm@^RuZsmJpmUr^Yf@eJ4S*L(8BC-_Mm{8&&CoA3DW#4~4@DbZt;8qglsSAEBeQnI_ zjw;oMZP(Korhm@1RUMQxRnUuC@5|vkaieM*KFzGAk}adkF0CBwH{Sf0KaR7JbXC%J zRX0t?X905S2@pMwW|whl7l4v}!Gb02Bf_x`=b~f8_BNikoEdZ`BM_mskr?)Jx67L_ zf>%vR@Ru=j_NT-iTA8%G5Z1J=aNqe3aeX0HH4SR1$|8{WM+UJC28iyISRZy1?!$5% zQLBV?zAWijvz`w3Z`HU+k!IpmJ zz%;T!ooDs*BGUq$*8$B)x=on2$7hveBLA8s``B!xy6+te0BVu%Jmzpf<t1F*O^2vd+ivi2OC+(^?3GmcDFqpZj+we{NS=ec*^}|ko8)O%7)+&?I3P7J8z^J|tsto8 z%>w|-Upu^2vH=^H^5yC?slo6BxZxi9T1L_2WbwdAj2iX|xL)1=X+J4_`#p2>47pFv z{f6)A>i!`G&IllS97CVb20l zl{qE5?)cb;%z(_dN7}_rS_-oz=v(wOzU(Z<$7`gT}4%{X4VL1GUwCR}U!$%h;!W z$VJ^%9HWF6{@>@L%9k29RT7c z{3dS;O&`r*vGAHyNB4yL#KQ%D%(ZbtSgAK*^`=3yN$}8FYB=ZQrT_dipBJahAo#u4 zw!yG?5u9qpV)0)*0!`=7Gss)Xjqj{ z0w(cg{987~`sFt!RlywX?<)je>1v)8=j=cSD#@I@NLOxhDEh8rlE!i1DNn3_DG6>q z)C!zz^X-P?q{ssMbX>v4x%WH9q{&Pcz~iS@{;=zxYUK%W`58o;KMz(ysG@sO8wVWM z8UQ_Bh--2Iz;Pl)bwc)ebA*!1J&`br0w^h77<8(#!>6fV$6OC8??UL`sw(R+Kf+5A zOVoqB+;d?g;FHCRnOPC=MWU2zxz2iD-47eXw zQelje{a_bVSqeI9;Ic#nvqEYPz5$QQbVdv~$y|=^*Jv|Adi(*!yu9WeB1-+y74U_F z=)2t+%uZsVDYYE-L6^W{IwtogZ4|7(QF`RPV}LN8RRGf)Ult#~a7|3f?XGlRNar-^ ztuGSz}pFP(T}Pu7eJ;1Ct=pZaZ%ph4%iWDQvE5%B3mj|Rb?A}`)Y zdsDPgOhPrm1b_gMT5zU>g!$vLy6Sn>2ZA_$bYE@4NcOk`Wf=s@&#m*(S4WNvPh|q5 zR+CmHo`Up9Z_YJk56dq`%CLU1iEm!@J8W^mlX}(jXhm1;ose}W>M<>f<)OZ_24=my z0fq?G=nqvG04BZBsGmnmpO7Ez$!%SU`5e!lQg4?egqQF6t%kYbnP`J}-UAWa$HRnI zr9U@IR9w{&QPffZ4O`u+#{nlS+++B>M$|GNrlK{n7u89uD($(TXkhR?<&X|avrufp zp6>(DVQiN#l_@3=KqREUdu#y-3LxKXnv>1A8qMN%20p4OC34(uuRKy39(>Ta?$*%H zg3OZFsEQYTlq3}cwSC20m_SXabSKDr`a@EsQnM;A2yNkXa-R}v@ zfB7pRINsp1o^ZP->U>anWre_|GW>%^etKJv0hiu8gQJ+@O&G&N4s=Q`r(`sY;FE9; z?aHBwWHS<`X2X6;QJwnZ!MIq9g`Il{$WZV5D=!z-2CiBRCa(doh!`yPC8Le@un|U}V_c7D>5LPS{=IlyJ5jk#fjCBgO82<8JjcnT#Ks2FKr$kl=sq`^0$L2&y z>QzD63UuQOx|0ZpZ2!a#i69b9oVujllf&16jVbGo1jD7z!?OTt zN(J>K_c#1KpzLMGqd5YjYuI#TLRDRUpE;LXc**9<1Km z9B*sjfViuXf731YcmmKxqJ*-o($N;K8V@Zb_Lc)lo7~NNt;kU;SAGa7FfJwUVUB6* zMz}3sAJp~mnN-}<^Y&3Js45`n^dMlO=<(fi&|IQ?{T76 zL_7klw*&wPvL20^o`p@AC~7jWrqj!0WGTQhciX(t~je22vDY?Z`Dr`_S&m9m4d560MM1ggs)7udc znfT%_h-Z+bt6l@ArW*D`X>$Uci5kS6xjyf5unCLxPsONqD)PI+8=hwHAEKn!UT)*>*Z-OSd^IsINzR82wjvFpGOVYoBDV6Ez$n%p66b#Zg1H608aq1tiSX{P$+*$YWwy)$Dp_bF~%#m=wOC^1n;)DxfZPJDC(} z5(1XHTCiqTtx-rhdL`H!@Et7z zS{y~D9T%v4c73xSOluu#mnFf<7B~PNNG?~JiruS(DGd?tb)7o#($_6qgt`G{n8e1a z&qz2>o6r97mQ@AEaT z6(*grC%*A;Cj1SI739SGQ#mK!1sNlN^61VDPO8&&mp}N=zjxI;>p!noa7*(&*3NC! z@BE;mKeypSQu4?}oF(i??jx>r6YjSPf0iqH*o)gt^x#vcWm<&w$*4_n=&6hjW~LX^%aVKx8jga2Ds&FZJH^zm&AxErh<{9*l!9hNouC7 z*>5=e3m&Qr&Q+V@i-4BA+p$*qWnO~^AZ-*SWx!+xPC(PVryQUCzLMJ4!?cO+;|R># zgj7|)qkTQ}68>ydY!EDc(u9N|bV6ju=$Y+BQDq$FOX<9GJ}40S?}yZR?a~ue-Ac%) zBM%7bl3E8b74F^wO*(#+{J-uXLqS(PT+Ufi(qU8x;LiL2nViyavykgauAnuNVPeS= z(=k&ViE+3uc1Vix;CTK2N=t&RR(qvz+2uVYR;W|5_**Ba-p^wGx`Q!Yj>~Wut+{px%kx$^hsD zPnI9;Nh^#Xsz%3+9iljyAjPr#^1Be#f1i<+_V7YTlY-g>`YUi9UFmnL?HAr0e*O$P zOc6!a4YMMx%)g9L0*S-)&D8`E^<(6MhGSMk+FYht^aFs=lgff7GwO# zBUj=i<~YuQgu|U=fS4T!In^YmzhVonfNSKM0N4JN1lKmGB&8nV?6)lIbt{ljch;L= z;#4kv&Hk})Lp0Z**sL&)+a=w7&l>KDK7X#%fBZf-ws6H2iK#`R&ZWTWJYI>6fTPpndt zrWA8@i?x1QO^uQFx$50ezO-!Z(7SAfXLwB*f5y8g58)lsjDxbgZ{0fsh6GDGMEXUV zPy-Xv@RuTP-ygq8=$ub!p`f<0W2gSrwLmw~A%qr_+z;NwQe|_}e!+ZFoqkwj;%xfB zAKY8$VZQzPrz}BgB3%2l!cIc!d#U?(|6D8wXLMZMO!7hiti;u+F+KtY=|~bTQ^IF z(c0;Mjd;023o2J@&^Dv~8-7{Utl0J5nY<#!Hu}r7ArGir5oc+gXxs0w10eTNnJf;+ zP-dP(F6_$wFtvw3`~QHv>EG8|T+K4@@ZM)bj`GIU*2p>8{EgrGVk(XL_49Q~H}60S z6lfbP%)r4A+xB@r#@PoYT425&7~d(e0B`{9ZcaJ-FOhzP>pW{1(hECxvc$Y+B|78R zw>d{C_We1AzT>-9qGZ}l*z8-&BTwwglRV=Zx=@c{eleZ}40RO8HRo(lehQO5i9YEs zi-nv!YTNJY1VC?2u*c(@HhhrEe$JT;jP#D^a7+{}l|-CHb!*8CE9M-MsOBI;Rz9bp z&zN#um<(MR1jV&R{Bqs>49sKxY+i$V0WQAX{M`IYhO}75&#zkLz1hxN+p~?eufE<{ zHvcr|Y$yKt+bSqE1?btBz;lJFPa259w{L5I`x=0hW;-!Y3lT(H$sf;mpeT0RlW*qK z2(9=W{D{<0S)WL0_%5lvs&wEZ1($$Oh3ij_%7_6x? zOexlTG*3cdYO&21)vKn9QNW|YSZ{Grh8MEA!ERH=0d!{|laz*5#iM8+j2}&Oqsj5; zrW`VvrI{7G8PB(Ma62$Qnk#`e0b(DO(hugW2+1I*^Z%eBA5`%MMAlGV698Bydun|J zn)tQH3H#~JGR0c20oenZKgibG-+NxfBsQT?)W%*I_D24o+k-2o9ji2K%1<~q@;C;Ap24)&WAmp%dimpt57 zig*zOxaNA*?T>%`Iq3GEIZZq*sAKo4$V!C-j=5*H)GS1Jw)~#P%=^V}k_uHU4U2qh zANZZcNRxrOE8i2n!Zt|B=7D8>hO>i3$CvPpSEfxod@py$CeU-UAjIQb!iRxy){Pnd z{8k>q48dsIbp8Y}ay)Xaesv=kR*SpS;ND>82t5E?w4nVhR=1e#YAxen@^%OzkTsG} z`MF7KS+aI|dueCVRr#AP+QP#g%df1`hOe_dw3@h%jgHxF$R=%^3ribs+g&ScCPT}u zF&w?^w5bgA<(A8{Py+sO7SFtKY3E9%DGmR0y`$rPKA`#rc95}+t}-{nzG$ijka$a{ zT7$Ewf)y-;4}c$gE&W8bGor%94z>k;Yt|tPslJ(ka>}($!$Qgfy?FTxdFd!RT2R@b z6!w@2G^d9g(*HQdb^C4}#VWt_9I_qtMt6nxldArr3rQzjY~t6SWMcM)H)gsL0YuxX zb!C<~#;$1+-u#flY+$l;rsX}27Ij;8Jr6e$&Pe?E+FJ8Pw&nCMlW2ON0o*ux*-}@v zYW($i0nWkqt|bAIL_uK;N=vF9=i!>mu{6;sSsOHL{dS^c#aOoawZ5J_tMvq79vVo8 zMJ?LJ1Ue)Jr~kaf#RNG}evwn6wN>P>ANi(0 z*TbNxlWr^^7HkG=82-wnDw#mXs^XkrMxlFOE@ef}2u`$j&|pwQ*G=+Fmz&6m<-xNl z={_q_uy#})T1tQqno}{0u+~xxo?Jb2&baEmm!8wZuuxb1g{*7AlXgBeI&|tmc_=9o zcXtBY^`};S1AL}3;SRh7;r5n~ZS{z5P$w20Je`!(d%64nX~%9>x&-F(0Vm4xB=(9P z8pfXIXc2zgB!uu+cE~JG$WH5d)s?Fr@9@yY!e3fZf%23~JA%tz0Y6mXXa_rBHc94F z%G$1nWY_{dE{#b0zzwx6#G@`kzQ5!}P8@B@spXw}TUz2Dra#Hs#a?9yw#nNgz+~h) z!940o^Wd?lf#L7W7gm>@*#^GT)zcZ8;0mhwb@W_2>@N+!)s(@=v&JO*y$h>R%cBs5 zlf95pTp(s!aA`nblWrteOJH}inIVTlR#Rmsaa!a%D|ga7dOOYV!%f(MFVDlC(L6g! zz>wQ&IF37ad8>H0US7{@LxKh2PcSPiW-0m@i!T!ktBxg5)kT+8TKwdLXcBMO{EC{& zIrw9c;2`hh3Zjpo{qbXhGZn`ncN^avzjO0k!j_73)$}uLwQCmFD&S_KGkb+Z`-Mb{ zLWU-c=bd7NXAg`5<=Z)rJLd}rVT3tNPV?3OYB5$T(48LmKy!5w@xCvFHqV{eSf%La zVl45$Pk-!>5$XpwWYI#=d=O~9a&+|=({Bt16fUh`-@RbV=HDfBOJdhRV|86EOP1~q z!>F&fUQ1S-Na#?!RFaY6!0PhnUdW}q{=JUVMx=bY6~WseTxpc?-S~FUE^olqUL~Yi zUCYoJYvyiuqLN0;PGu4sZ*Cktj?TM(Fwe={AT_4%RF zy@Gl>8Zfmj~8p&Mw+meLa?@q@bWdmJ&Zg=Mb*bg!^ zSAc?%TjQ165cxSgF-aE#*o!n2ag3Whub1gzj`E7kHPQ#f(iVW{jXy_a96#}5h3V_z z77lZK+Wr}o$y6=c6ZZJDdYw5W8*dn^Km8u3(8>2avZkz6;hITUt~l-`o&Jwbl0`a; zL5cSng*2ZHGw{b@NNlDaakCga{?gl2))8V<6KKQpe z9{noG-D?Nf`f3hrTC>@t{2*ts4pd+q%?sAv*4X@TRXlTSB^%)9bGvn-DeiZ?U%8Sc zdPpzwsC09%L??2kKCBmX&t?TrA*Y?EpN(etxzBk~H;@odxUnZ_~&4fm(3Cr^emM_(3}B_?+Pyg8)v zTrfFE<4il2|1uV0*_nnmDO;Tvp#N@2{`5&++S#-^5Pid8zRW?RfQJghcg@J})44$) zF-f_CHTit}VrxYSoj`e`>S$Iv{wlp20Zs4<;+Vhvdg)-BpHH^!sjYkFxWz-%VP4jO z{8;V;C`YAXt9v063O0mg7(HR)JTQVzxX?831>Mi1@gHSL&J-;7HlP0uEA#ijD$7$n zON*ExmLtx6n9^Ss(k6fPPblG3tGr?neoP`0y#^GySW~ks{i^4o?7CBzH)p4bP3it~ zEJe)Z3sQIi%(2gENKx$@jC^8hpq?TIS%;Ue-{cT&I|}hh!9TO1rh< zlMeaMO3?ou`~tj-+lUso`wDB^&Kng`4HIpfJ&i@h52P-oFNI^6gp~`*n*BQP_(QwH z(1U6(4_CxwhK|-gm~bK7b8D=uD%`x|a|aH<@FU;NVkLei*%sFsrz#Ff$+}_vs>C2O z$2lLHTS0kxBvBh_=s7_W3$DFrHQI$@3s$klBO(Tj+`_EIZ-?p%GT?NNL6uSa&t87b zxR_i2|0>cP_r{tAYW==g%em+DI5##Kg5>d?}u)@S-{w^k>qA0^qydc z@)CajfEtyYa(-c$I0bx({jvE=2ui;Hj7=eEAh#fiE+rqv3W*^|b6x9Uh(v?Um_xTotSF#2 zl1FZHBRIoXEXH*jjTQ-C`;W-KXE--g*!_TW2`LG^>sk3kW*>HH-Qn@tAusZns400O zHftqgF}HW)(E7RJjPk3Tu1k@HxbkCuyDw=gzHT~s@?CP)F?o&r^@MYAVCN1WyGJ7}fDD5JFKN%+I<^>dq{Yg~eOk z;DM?5?8D8w5PhL?tgPt6_9-02x^Xcc{TLO^!>61Ly7`tdNmmlLs`cM?NC ztB@st333@b6|J#SR`#IbQNJiG?{gz2R;Ei}j6&XVeAo3avn}Xi{cLbqnmf1>$pzg1 z)}3Pe?1pv^>nJUqOMx+LobLYs#Xvg0L)uUOP7~|*v*8Qq9>)NQiL~MN4L;UG!W?OC zd8(d-zd@@#o23BdG$o|@NeU z%gK6#=(SL=NAc`5iGa-^9yKB4AK;7%&1Bn5nwys3uJ&GY3S6ej3cQ0`s4{R3iozl}Uf`^)qXNK(Ecw<;aLiOt#*pg4AHGt=PTree zt52<9m4NIR3C#(MYcb@V))OY|#BJkn57J;V-C8-iiu-*xqvm7QT`nGAU zvfJ3=ajr6ET{~ifYbNcjsuGYMink97tcCDB*SnmyM|rZd_gaGWj>g#>Z;~OMgG~@O z`0lH`^IhB7Q*bbu*?IoMz!^Rw%c;09*$!ecz6Z@i_gA8`K6>f6J$313Uq^ z9>?LubOyt_%y@0`=nr*-`1Sos%?Y{!xa(})8_)7j0vU~*Y3mqPG9k}>C_)4B@ z0$&L#Q-u7<1mUrZz9F}3yl-&yo!6F7d(a}+7lY@@lLP8(#3vxkUVeunnYAVuLeMq13~Rc0yywX?r+$Bf2Chvj(lLrC@-@y4>jY;X1ztTlw_ zpgOot@`MprVr*1fA-i#IXk!TBn0y!pQr_I4Hk;urVmBWJ-`qT}S)doLwW4nd*vf*h z;g!Gn$b*>Dz2wygedG z9hIBPOKoN6#nqwxm0UgocSGGv)OaLbopfA<|u{QnrhH(okA+S^)8_ZQpp zd?hQn9Kh2LTqiMI2Y#LSZGcaE4Kig!LuXn*YDhlBMgaFvUPG_WVB#M}le0O}ICEp_ zC$~Z#C1v2<3r*+!HHgoCWNKWO{gcBwefW(B)m!5Yk6DdB1otk@vnKF7 ziEp|PqRg~i^p%535)E`hS?2|&t5axfRX5VhEKl26$LZUhn00U-#b7Q?&WO+*c1y!T zBPa*y3c!^^$(5L03gyB%@DLo=KMnB5c8>Noe!pAMi$}YwAbcg4K8d+qy{sO*T`3ff z(vN2`f7=CIO6ThZM~pDtF}1a{v%)VL=W2TUy&`JMBL3FQSoJ4{aWstvY2t5<0DP1X z8XHYdAdME2U$vdgNd!kCX>7jfY=|6v-AOXsJa9cL;kZ>9=5?ZNsRA{T_Bd;jKD5CN z2w4E|5L=VqYgXvJ2Gq4CT+339Tqo+QsL*IOu%p4^G?gW<61W$^dGKo{39dk0DU`g# zykr?+2>x$G;Ex?G(bn+RqgC(2m0YfjP`yxIz4s1Rp)^qj+&A(pJ`=BkBwsxk}L>fVQvMJ3@M?&ApHoI}UMNpsx{z@0*Q41nKy8GuW^`R4(C%hB$}a+u$k zuVf{ct^@Z;9>2sKC?nKsOo!fRQWhb-mY5e)oxF%$qI1XZ!PYNTgHphJSAznH#a^Ip zUFHCS9kTzn4p8C??(OYyjFlz-mGlTVeEsi|s5=6IW9o@dYL`)A$r>MAeIG(3IG@d= z&4{hmW7SNK&&x;FWPH>Ur8}!>fCto(c>9RFs!(!uTHRu=;G=gzV3cew9_OK*8dxfWV&Soft*J)8Tpv(xf z&F%35%z?v~8kH#-8Se^l)q(5G4k>VQC(v9LQ(NYhrN$oLvEt&)X6bq;|3<^dk}FZV z8`U059e5-7CCdmuZ9+sY?Th6Be)`&L2cvTE+H0>Jw&{^a-k;A;nz1`yATJ&*n;LoI z==S*)kT(wIE4ga2Y%dhmfj{GBRyxS@_ybCzq@nCIV-jXB&YbjHP3%a1&1+&PCRr0B zne}00Hc@qIOxzO1b%3=k+`guig1CzZi@MqBCuJ!plcV>6^s+))9Ohcd61M%UWSCsvcz4w$}f9it9e1 zl4W#RnV5}kd`s^E;0`^2h=6YYpJd zS_JT0E>K_c)IZgMeE*H#`IkD_oUi1n$%RrV0{E>eBWxl{v?`RR!(XNx=3$she9>Z) z;12l7Y?nUVP?adGQ0SdkniXf;Yii5VJos>Cx?IUly$MgCoSX_K+sddZJnwTW3`nS3 zKIglG>aJ8g--F9hyKD+itpfGVOI!`XwT=n&>#U}D&AA;?DH7l=AZr@0%#>H`9|Tq|jZ3;&O%HessL&W-*)?JTEc-L4a=;c&7k|*dL9K z!-L)52l$Igpq%ZW{6Vq!KNW(%vAF{He@m7Fcv9f45?ob;_cef%L%E^I%~Rw?MdF%J zd}<7`C>qp)H2io6NDbAyqdZtt4W=KsmOZ9k#j7A#dcto8ChgT+j*i#NQOy4`Q5Q?D zrte=@qS|dUTN7v(#zX2@)y_(Dn@sH>pW~J;Mqynl=jYpiTQRr3zpf&1O27^GMr_eq z7Ew8O(ytHWPKD+<$U}8^Jz{ftx|5py)eYmOjZ_qd@tV)CLb?4fJxY(9lR~-Y#qfIy z@Hby;fp>`gy8u6WF>&wd$0!SBXHnXH1@Qm5T&$VZt=x%8H^OVKQw=!aMxw^_Fl}_z zOYb4F#!Y6M3Ke~%cZJ_T+$jvUl57Fv%sAK$JjHq|TI12agyVLjlZ>&$YQTxW%Xz4f~Q|M-|0*`^Q$+1o$ZJU%pO z&)zuR{CIyf_73*<-Z(yPS&-f4BpI@QuzT|H=E2+Z`G*Ia2m5p6?Kd`$-#Dqn_J{nO zHXm;u930j^<3P?bK7M-z@T(|G)qy94q9jVoptN2n*FFCD)8k9b;5ix(Lnqm`z?T|9 z6kHvyMYXiEasju$BdKjZr86#|krFqE2R4!zPnijz_fbuBK5X!M$c#kgr{jjRS&oA# zyKv<427HrnJL|=0L$^O!uXUsr2+GUyo;1IjaTZgzI*deT7l_xVt>_rNQ{`jO?He$g z4$!ewmoh7CCtJct18yuv-gg?-zYH6rn-W6tyRJ4?A))Y{Tj?*B1HY&Q;CJu9?*#xq zZM=RkIzOFZ*G44 zMq{l|ySI6Ga4chY^c~B0xOv!&&CUJ&V@>0~-PXqq-_3ZNu=?$`q5b5RxPIJpQNI2e zyW_v=@wor(6~M2cEPIgW-tJ1ZF#>;jP#yTwPbb1=EIXgFqeK#w=k;WSY)KF<Q~3H;ih_Po?KU$x0^H1&Bw$U%xX9&WnC-dJ50AjA zZY0}_JkfIgGHh?c707_QkHcA2Kk#dBx>E?Bb|c*T`v4BVADb@*xG?<=W)JW~WyatA zh-J%5%^!dt9G{*ZH-hhMo;GD?ex~_!J8wMs9|v*v=7(+iaPzn!_eAIx#@`qLek>-x z1)BFa_XY3LAZ`I!fP#-zZiC^J%CZt(OGiBugR&bsf26&FxQQ)XhACZh=2n$;;7%nFS3+?k@KB zCm8O#g==E&NFBpc^q+>`_@#EV&X~e(KH+gLoe#=wgJ(@yLZtfEG&&NZr=XDdTO-FY zv3Z%fF53z6F**n40ycNh3-MBTd8t{e*Iqw2sGaz>MT)zZv?R>+#+dr!Yb8Q*X`rod_)6(0~w+Ro)Iafb(0;LgK)r736ny?727+j0V zdn2euvK9e!psoRqv%(?+>*P;JfdlZ%y~O;s9o6|-IdIPJG!6M$7N>YRfZxfP_C)|k z4yFJfM^SRO&4=d$yd}I}I6ZBj#>lPB)3f=BTu1Rzj#Zx5y zw6a2DvXv^70j1t6jw+WHOM%dpGSNWbmCAn0mR5ISCgDw#ohIN&t&IZ8A-cZ+$2FjlLpk>(=BoE}GZDdgoh;)i|nL#oR3o;PYkH`vaQnLrsXz)FC|NWjSm3 zqcJ6G5;nte*;t9P*=Rkx`MQ??bw%B|_R@H<}X{`8htB(Us<_G z3gsEKlO3y2?$b$3U1}_VBg>!~q=WXJ-j*Rba2d8SvTcMl@RmyxS&?MALAHK0;BbnGE%vnqLHLqvXY%%&1*|kJd&(n195<^bO|`0 z4aIgXvEW1UdVJz?rfkX}6Y4wk*2#KtoQu_*JMqJJRb1Gd1@H)oa38M+;nT`OCV9~l zxaVB`&8h?E91jg;bCBn+1UQTROUzr3p5IsqZ~^-*VSEa3jdH3;CCB6WDFD8?JwH4> zRuq1wVHWIQ$18weJz4%rwdyB(`-ARA*a%KP za5`D#R?aq%49R39UP78k=sEw%r=Uhd8xLt zNn|sH;&`wyGk4h+Y-sHt2*;xb(oBJ|9~)+KR!sY>FfhrRAUjcgj$InuYb=Y?)DqHw zuny1X;x+jiwdqJGI5$G4C%AyFD4gRw(4UJRetU_-q;pld%Gq6u$%97Jtr5ukQseYO z*}C+dm`S00_uY2^`1B+utz8#Yp=ctB(gS?g9pe)x;c8tL9 z5Wye3_4f7#vk`m*P7c!N5M~*6quYqEkqF*K7m)KxF)5iWR^2z%B)}S)4`w`z8V8;t zGT$-4wGdWo0mb2baw{x*vvs^0^YctGl1mpqWh8+1a=|%ZpEe!L(5F)v3ggMY#K{b{ z(daVBhPCpgDL9#L>IP*rZCukd!s`9{_^=byx+gZE;9lSJtOPKnB4>ue=o zvf57tk;f;8n_zYS;PiNZOUm)-!7tRllVh6>wm;s2yt@>cQ1-XCEWky5*9yM92;dZ` zs4)CY2LG2hTmk$F%0&RqOH9Sz_bCL|NqVZlBVfEQ6kIIOJF)llTD3t=Yn2_5rEw^~ zG#M3%%jZCQ{7aX?{$!*zeF+e2pFS0e=gd!Lc1}xD%FeG>HhZz8rUXgmJE6IMsA$}0 zRq!)qm&REsxEN;Uc5>e=iPoIZoHbFtT+o)&^@MHIpJr~|amy58+dT=_v6WcSCgrmY z?vPwmu7Epv685wSKqpPk%qGabG1C1v0N+Y4lZnTPXkZya=39!o>i05~T+? zSVySm0GXW4SxRoL#)UxkOP_CdX2XMEORSw+!z?Ha3S z>=1w>TZfyHH?|ymON8QO0WN~)OFh814%!drpK-8DibwkK?&>Ax6_h1&;7Wmyb>ON( zdG5a3lmk};?kW^Wn`C&9ya3xVV~ev>!D(=pM}N?ofV|#dI@oTevF?vNsKnc>G0W6? z-Kthp{&nEZlD|}L^hsFH(6xj`WlQe`#cU3V{b3BUp?8Ar_$I-&R5IS)g{66h$k9Derp;oE0ry!ZCu zhg*>SCG<56+C1SZwT^!F#l1$78yI6@bn{>Tbo&YRZCBjZ*j4yWWKRFyS_oHnOo;hRyZ?>!eS_ zYv!sIzgb7=H_pA0=WkA-Aou)LtpQ(BX~`0@x4M~rC6_u%|NKMSZf4c02ygYJ^pEQx zPwq4hvMT}~)tCtGg=aO>NtVdEP|hHC^*Kp9E2pb-rH&aM^lE`oh$rlp;I3VGvM?G3 zzi+aSJ1FlmF5Mo?tS0hKJxPS-gE*4|VGNhk=On|l^B_wIeDAG)kgU3Yc<~o^jbKREqY0}n^JavRA`OXST{3iVnPWTiKZayCNuT9L4igY$8^pX{g;b2k?zDQ_xEq?Es@+Q*AYrMCrX z#ojb06uoD%suV>&$?Rc34Zb6IJ;H0Vx&t>WX=eq$-BNZ#G5F2!I}!MwszTxBG=zTO z<@)Li&e{KM04KNo$86qssa*iHl9gO0h4Q@nfpd^Y5%_bB;7{u)CTZ^gxevz~Fte~& z*f3}oT3wZrNZhC6i`Ed{DHGG&EoO__b-WT#LvD4s6xS!4V&cin zPY8xG0_qsCb0m{Fi;2WD2I@(fVCGKxkh|J_7u6GXS54vC=;Kmi;_#+wL2#tl-r9i+vIt_5^I2S)lC-$>z(3W1 z5IdXfN~jIB`MBSp8_A-(LR_1PzH_3I6)h%T5+x~Xwqpj&I9NlUlAOiKu^c(Er)hEK zB6Nt&tPgg4oi{m?YAahMDrrB96u}vYwKcCXaYuvgb&@DTvV;Ul6D7hcT{5nz3dKmL z9tAWPcd=YHC>2uzsPJIx4bj8(J{68BrVG49Ze}+ntb2C`%D%~Xw^=!DVcfyEe1>$IS!rw46yjP> z3Ize!ITDXIis_#4yP|F$`%+1GvkJjCk^)Z(WpBv{JOrNryxmLx4+0#7KXtTNwzuXh zS;=443q@}+6@lO83mvK-c$XzX$7WavcZ^(%EWD=lL^Kw`+e)=Fk?l-M#ox}8`v{J6 zDUdlgVrtJg;YY%1z%K5WBOCBwzX0q9-I!U5?97F8- zXpA@5W}uu9KV8A~!Rfb{Txu);vmkSv*V(zYh6hSFThC}wNZ3YUysmT5imFhOJ_)lu z>T!+X!y53&mDRX7nMo2{pPQ=)<<685-g?0Z9Qop#U*t0r`*WM${SyF3Hn!To`8jgy zWAl})4nl{%z=yFtqzVlz!7(KwuD8qh`A9OdzfGb0-1&pq?3 zjr*kmY{W(2T0*oQJlo*6lS`>bI+`U6pNcjI+Er@eDN;m_8$J9+^~hQdX)r-pFdM|+$}krjYn%hW?WP`65T35qi& zbUtzXP4;^n#qgUAa3AEkXfKqD%lCKP-2;3jEBTqdb6b_B2BL6F>nJKC10J{H8NoB7 zZb3!8@Yd(>MFjP+&C0jRWsHaZ1?7iHO?OgN$=;VAMRld}aL6Hmi{QSIr}0~b;EKS7 z@Wjw4Q#u@WM{vx*_m{{dRzZFjslzroD|CaYxno}tsCK^@3%U_)OwE?N1gK| zF;S<`JVKBi!c)X8-35XW-XAN;uK;ijb#yX0Y@+dkdS6aGH}g=SlJqtnvmHUr$R<`8 z<|1$0)0I5Y=VBAUK35JxXa+2}48U_zGwk*z-N7^TLh%7hT!iPsyyz`9Yk>^8n0?aM zOYvJFxHK~3+P(E<7pCMOPw?Fblq{jxB|tk@GUmPUbL^+V{AtPveJlOZUMR@j-GlS{ zr@QACz?VFfa`ZxnS80awdIxxmz?*&kBMUEtMvdd6z9ls6~m`<(m=P_E^ix?A9S&}5lz|4!{A<;On9y`in z7rZgLBb?vDdRaXB^$Ksr>jt|4E^wRJO~prTINvQ5dJ~wF@)oRjxrO@1Z8=hfGIG0g zstEaw%Rrk6#S?iHU#X^Ugyc~8h%al{a{_q3mA>D| zGY8R8q4~Vd+CL=08O94RP3_&PqwJFIa8O-b*FYi##ds%}mg!xXnx)QNVNo&JmNlmN zc+OlNo}_i$a5x5Ykj|Jue2&R2*|;S%$1B-ff5M)CecLhkkg2me6ocz&%2}W$0%zho zr_WqUTw~~z$|l_(G1G-GXX@3p>TSr~^^|ybNTaB0VX`R(IcrXazoZ-rIdJ%GfFFp! zU%9*chfGo|fV#SY3**AG{4}!C%r{LwX~JyZhAFcry{mSRkC_5@ zTj)nNVYj8&t4B>`NtUD`c<$Vh*uqnP%7%-6I?9uSJOKPqGb`llyKf$h;JbvWMPxk^qNueHNPgl!hnu}%ec@+X|c)DwZ|;F)yy+!@?X z@9}u?4G-Ykh2Sss2>e~{#5{bZ8uH$Q5&Wlb?%sW50es2BDaQ};aI5ii4d6}QZ+9BE z8y&9BXPe@Y=o7oqra!0oN8xS=8u69lQ_Hqne zP*PcFbZ0#wIOAV|+KeVOp2bW!Z48-{7`dAPgMo=T{8lh{}b$zu~BbA z=MxR`^d^MvLnt=|Ce71NV)|Zs%^mJN2(LsrdG7I2hsQ8rGwaNKBMiqN5XUn1Evhf< zQMdpuDS*Fl1mMU!cXvM=Mffj2+%GSE3w+9;AY){x2%e-ZBtwrvX}8_f^`TD(-UNm{bVKB z?7^JLjNptKD=kb$dOL&i)VSb%fi}8{4W>7iX#LH;&4|X+#x`#q)|CsFWAiy4y<+k% z?aW2Rg?O#_W(Ni3eX%X;RX#-==fJ%KZOEN6!kQ=#x8a}J4Zzt2$4f49(GUEki*w+W zLwW944jlRVi@UoYe>fsP-rari*&_?!OCDA^2Jn;-k_4}hnAiQuN1vVApMdk2negKn zg-Ez9?rg$Bfytd21Jlb2rl>>qwFJuZ6B0RQN@ zB}*O-`TlSUg)$T!rN37h_Qdwf6~A9auB4!tN>|Y#Qg~$#(Bxy1jK7kD%TRk-Tjo?ry4nbR%^7TSDqP z7! zUq|V$kLo9IS03DRh#j;?V7^@OV$WR)9bb=7#|Pf!O?(c+<>4uY!_)-f!)m3yG2u z&sn!-SZmB@&Rs@#GX}t@1!paSqO-$z5tWjLAX} zixV%r9&gTMLgHf|FH|=d-I29A83o_C~3+h&4T|rW?3?*jt7|I_*!|HbQYd zo^Zf)UEOe^acplp%O%Iu3kuxOdh7E?KQg79i?d$ zgOAOuI>D7eSpZ*hYa)Ojxf3((WOXfl&!D`YlbHEniIUz{e}(RA{$_4oMOW`9>l$M@ zc9<Q;uGRV5NM)H@_4r0V6BmLkHFX3 z6taiTP0BSYEFo+ypE#aBYvZ3q8Un3DDOIja$hNw1@I-eB}W1N0T((@hVq&S&Ox4Q zMc10F7q~-n`vdK(o`4vyO&E@bz-LXQta(N2uF3CB!nZ{Fa33ZJM#-`GA+PP-$&**t zSqw?i<0Ua*yw%V@D+c{|QG=c=Vy~sUsA=#wRqfmTOTa5Blp{N<*({nGBo&;Y2Uvg6-fFJ2b_^QhY-)#JD((4EtIawAH!s|M60>kK`PY6QDlve;nAQQM;Pu$ zZ-RO@hvAXnw%8A)L-G`sXM0d>H63LEH2LoFduBz~G7*R2?Jc%0FrHbIa zYLMVtD{X2}C!L}-(%#B~?@(US4`k|lX5cI&&*k4an`y+0(&WC|N@~FC9m@!H7SsGr z9e9(K17C7uax8`N6y`tnWD~CWGY3`#O1}jwSc;F15YJ9 z>J}dt3?jMMA$b?MBv#DzIB~@oanBTB&z$BR7hpGxwZ+b4zA^k}nhswwqM6tlYI73b z`DWR5NWX1ZWKXubj~*RWD;w>;U7DhY!}5LkOxlGHmvZaJ(B6%lwuT(QjOo#9fqf}C zlF3m_`&>lGH_RfqkJ7Ik_>xMq+YN$4Lp))SY@O#Wv6khe#&{rFN8}yccAZmsei--)8e&Nvs)`%sfL)_ zDfBMIWhfxs-r?pRFrFUZby-iPz~?&fFIEa=$*swQ^7p6LUu#N7!=U*H%XOadkgwf~r|+CdiJ)rsPWCHfx5o z=S1Fuu{74YUB~OC$y;H#*35G%!_!xgVnMm{36qsD-h(9d-I)s5(+MCAjJ%l;nZ`0J z;MJCv<}jSe1Ru)3P4En$TnfK@+Qhk4mqB*W_M00p&1g9a+tqq-Q-fJ9HCDJSocr(& z_24q_Xr&2>7wv?@KwP9|C^o0@Nj05KHHu$ZxabIeF~Bc9i9#! zH!4za4)RZkXGh~gISOI5Hm9Ocp61$B-o0~xp3j%QHcj@K^E`^GJG;4Y%|~`NiKtB| z4&wD}P+Fa2)le2*w?S{un$XEghJ`@i_+fIr{sz$x=X5@k;U%V+Us7|!X-3Hd&K39}2=@yjQdqi`V^-Rlw&m>jy3-DFT? z?bdwAr!c>3^y*lFQcCC^3*C`+06b$pOL?25zVI|3(L^_{U%I^u)F{?&AOFZ4b>w(S) zTJX-I_rdBKgkaI#P{sp`_td0xPyaz|oXx6Z^Z~Zq%-|fU4c0MZ+t)ySXelFMVTHSCfq^9O#9@DafyApv1+6u2V##^Zs7NoQ8tawO|Pfr0{bttYP48SQPTsiP1w)eNMY%FV;&>m2i+nZssUDgBjGzll!ZB$F=3nrkhZIl~=Tc=l8Px9JO_oejs za>xg}(9Fw17IDYNZj89KsQ{b|H!g3%3vmZhV@w1$p&588?yhagmRR$UW0#t?x-oO_ zNpCLH6pMBJCHG`@^Sn1^3*3m5GwBqE-a?MlI+ z426_8p|yz24zLd7CBCSqkX%e}KEwy=>oMJ};$s^MdekV{89xLbIE@V5V0n`@8Ejlh zi)X++>=(WhdI$Cjm;KpNMjLuK?E^eJH|-e3>V1Vnvx4ppY^TYq{Yic5Y>M!$E7eMh z*o?t)xvJ76`IWQ)moCB0*0(RpfIl_>T>M@DUvi7`Al(SRRX^}IsRQRmHC3Tp2RLVf z;Dax7Bs^O|At?^(XF$`fJH+c&~*V(tLmb%j0utr6sJP<G*AQ zYwk@?=!n9)V{=izuAZkQN(43+)Z;PfU4e3;@s8ry_oC>(zBikZIt;owUB(?rU}a${0+nRU|K4QbVT!(uHc*2Dj4_M^s%&% zVsO&mgwt&k!8P{THHPO-{X(^Vr?yyk(#ujcVI8H{q{PX1m4gWGsYIJJX0gh^#cYE1 z>V@)5d;ZW5e6^EZa+`8Ah4N~XPdUh=2>ky3{Zl`OnE)J;L+vP8i*RoP-W-;nBxT?1 zCP!_ygKdyrVK*SJNLw>bk=%68jrPX3Hx{sm)VGAW**w@&WF0m3xNC~XPmF&k4$M?U z4#{O;*8xnsisv1HT5^$i!;KF#QUg%WV-x(Q1b7Poj`AK_|SH%9bQkq-jy62x(9o;<*>oxXu<5 zDtI3abD2`7Vntf*Hmm{7>Ub&--bKnLfcMC{lHlIE zkx+Sp8+zPbkb{;4=k1|oEGI7YakF9k)V$iHm-d96>K)*32imza4S1&_#b-1JNy*@a zua@$4Q?aBs5`0~$w&Z5z0ehh|X#uzx{(d`5PYNZJMh&VHe`gvO0&o7P>{O%V zzz#R3kqyUy;Jik&(%bg7Je$J`*6Jc%DR$Dia$6b2;r>5uXEi78YD$ky~1 z`6r=qLTmPUz+fM5wrshD2eH-}-4E?(3nX_EMW6=LozTjVyW~*C-YWSPuAA0_bgE5~ z@NOcv0&>)*Q7JfNy~omTt*56IDR7x%^&k#T^ALh}ibHTm;0xeOZdVS~fs+Gw8KH70 z_v(eB2)xN(T;VV_L z-L3#|P)_X4eQK~-&*`MmJKMzB`s$E^VV)iH`LO^(+EsOj%njSR&0nMyTbr-8p zhN9Ux-*dc&p0f$XWlh~pgm`}CneUXJ-7%t^pgZbx$`T2$aRu6CdL`O?2j(_52``uf z^b~?~4Q3%;fL1In8;Dcrty#Yl$?du|%?B>kC#6kN5jO&7IFBDO-xj2?h|4`Sa_b$j z%_?$n46a{g!Y|xuOcMOv0{Hrfx#TwGSPI3xP+AJb0B-J-|zUq ztlfWx@hoBNc9Yvi%L#m^z<0=0d z8T9hGj@joqgacXE9wJu zIZ+aLBR0HXhxZB8Men+ho|#Q9JCWrkC|c~HIG12TcpAsj`Fq=`JH|_c@5)xshjGYP{rT<={z70h~*XUspR> zRiQN5uRe7%wL&Q!ixF+uo0xm=y98n+O9HToov0lT>rEI7-PNzE>xAERmM1My%9(3= zlI_Nw%!;S63WXsub-9AsQ31lAZx8o~Lznw~z3!C_r_#|ck z&V>%^DE*S#l%rKBZ&ePQGD2?T=^eqZ!#(mcG@^_oFLO?pIF^nMlm${8Ik%IxgM6t( zU~foHf`z%c=DsHRUwb0!E;wvC9pl&T&>bVHm6JIycH<-y4XNQA4>m%lpTa!Z(xXMsmM)+pWf$J#! ze(IkvuLb6e^JmN~#Y(&#=?=Sd%MPC1akq$x4?=qhq*eefG0nBg?xw=;>UbqHH(laj z0UbBsW)7&6GoQyW8=huKc8%lrOMc#8QWzc!y~k6RUP7b*cL|rK>1~gGe7WR=J-0WA z_2RM*W$r!FcEPxWc2@HA$eki^UuG|cbI%&5=>6kLwZgaA3%D5&6COmfaxuJ3ZQtKu zc5|}F-3ST5Imc6FgewAHa(i;TA2=x#j?$9@Cp@Mbco;ojX*^P#c`Ev}NP8lp z_O{(;dh#I>CPVX74~p<@Am43Vb1)NZe~bU!I(@1w z$I-j%^|gwXOm|gv_dIgpY~7lko{X-?*j6n~RP%My^-PvHLbpkv(-t=|O@WW*fj{>j zng_mH41a6~hsFHiJ1XA7E9i+`J zST>staj(SHEnV`{BH#_&(poTwA61d+xSTpj!kp=axLt&>zo@~4ca&%~XK;jfEBv4Hh({Jx+ zSkGp&^&cSWF~(n^{VlSd{VoK*f;{ZDnT70dq>C?oP^zHWfn`j@@k-T}-@e<`+O$!y zOrgXJc_N2G1peIXAGg)4E*^d4?YAQX&cm2;_k$$&S)w@_+4_5r!wP5I_hbYf`n%Q? zeXt>-U!R}3@G4ejCF*7wU$;4SR0h7zLR~AG~f9kZR%^|5euJt-V8&<<;55OkV)o2jhme!5I z&>L*y5ZQPMw;?mrP(lr@-T;9>e!s=jS3a@#qbf8+Q>}hdQd#jVM0!1~V>u4Z3*(VH z=_!-~_?!Q#Cou&=LHhOpza2=1zXag()lAt8+E-~W>%uURhacY0wl#EmX{%wm`y)i% z56k5ZKgZt(aH&n9s8cOw>)o&$5mn9ha=7^!?T3x8R`x8^XBz<5RP1e$5)t?p06Y+$ z6Tu_?jwg8l!P~I45t>D(e6N}~Cq3S1EvTu`GMW8^*$o5d7#NI7dLvHx=BsZc1#`A) z^bmb`HN(!{&q^pmWH=bmZo$3YEP!pNW+!YOgz>7EWI{|m{4&^6qDUPHXlAY&7Qn(nKKJS}1pZ8sP&2e0i%-a7V8{ z9pLlp*>S%dmW%alJ$QcW#jreV1a)56sYl?$s<=Lp5|@5KHcg zvI(nE3&`VWnVR-WS*uZXlV+6KrtF^hdm269YHPnssrh)FkkFRI=1X$TDx1J_hj+M9 zO^K5c{|Vdq5*|QqAkOp>_W|djHy^sTuMhDEdD_~rn$lf@uU_;Jp8~iUZ%M7kDpTQA z6Nf+jWWAOC*$(h`{|(a!9~j`LTM=vZmjHYvw?nR4a6O;Rh6l|xm_B)6*=73V^bZ5P zAUs8{JdJ;X-@tIyrQ9cXQo5T(K^=g1nZ)7+@OmlcbFV%B`s?Rp!9Rc+NMuAP8o_~i z;2dU$2ZSS*ahEdM$cwmml(nMN#KCd!tyMg?=-te4$n3;)6xgVS+NP&5Q}nFiVEtM;nZZH1?8qd?6(%;O1s8gPg0mOQrryzT-cwA#IABXphf zozuR(eFFG+`YTiX0@PAYj5H?v=-s+ zQ0c!IdiNNM7pYzVoNkV-F(rrH!^1ufg<6N7A2@uSi+dP~*Yo3Shhu#|(hYG2lymlg zT30w<%#Mr20kxdF=#MP3&Q{K(^j**8Y`MU^&|$w2=ldlUCdc3+Jd@bNu-crw8h4_2 zes$?&^~><&4s}_F#ekPzBs3Y>2ETGR2uXI2n!*{b7K_VQhS;JFm|rfcPYk={-y}!U z{GbBB?=LPV>ogSK=s*De{6$E-RZZ}fd_Y0)T^<~mq8n`@jr{zD#$?tGeD$Yv5G}wmXs?;sgHY0-3m#)H% zi$qPt)#}b8BoIdpTST+37Kl?Cg8Oko;X}z~HXp&%uq?^xg@tDeKQ1C0C3R@j@qIdq zUp$HV@f}Q|aGUZbfbVAuv&R^i$aS>t5Bk+aiIXiGQ0na?dKD0c5r{t;QJq}bq-$iyr->Glr z!O@xOi4xcB*iJR}@xhud%^m<=(2vOvLzAu3Ardz&^fq(k7AQ)G^$ zaB7EcN$PGIw`Pk7rE?x14pzt9v^3x-9LuH)BG44XQ1ey5@we^q3jpjw zewb(g`649R&*%|5PBhcbICLa>gu(8jW*_VlOF3sS$L34-xTbp3{P<0m^$X=R6L3x^ zOo`30<#+}9B*J}Jns273kI!X#$VXcy9At+=mW6KvxUW=`&Jw!{_+04D`6d=+xU^NK zRd8h&1a{|5%zCGB$laTT!i~Sf%>X}M?;f7-y&o9y>0wUrCQw(WIZfAr%;3dt80I8@ z*A3{$Io+@DtlVYw0^rB+&i8bKdo`x+Iqv7cZtdZZ-kXf5bl3nqc5%InwD&=1E5e+% z^>8!5x3kr9IS0&f9f2=m_aR0O1Fh>_yV6zTmbxO`<9;{^B-YdyPO z?=D}7leiqASJAR!D<^)H0=QQ7ErXkwZy+uSP8vMsg2!#lb6)DeO>7onDMpPBOb>7k4`8lt42N|d<07C;RP4lPG`tV-E*i)j zG^WH3z6L%4cA`GMx znkHkT`5Y~Za~yEKr9h%+=&e6d^}FUhOKK`SR2gp5hy#wh28yA!*ruY978kLq7iFR( zxSbLX!GU=qT1sQUWyFeB-L|1vnLcT{$#GP?zNFp=JOMsl5BzSWP>f%11o&DuH0W&w z%rXI9LujI;K2YtU-A28ndP%ed9PH#6MaXb%+>HZ{R??HPKwsQ@R$X?{XiUA?-30JF z+)4iL{4p3$cdu~pp61nTUvWO}(v$1ZdDr%`sSD-&PZ-A)jh=Yj?4&n?Jtg;hq)J!r zqK?IGmz*5GYh4!nuFUuRIE!+bk@Thkd?&^)k8oIS5@VyqVNTE%#!YU;45KoY72wLvMOi z@&uM=@ExOS7K#|}3N#NxT$!t^~m^TxyzZZCTj>qM!UTWfUvUl-G%)uPMjR3z& zFdiWgI0II+ji78Pr0jp)BG=)!0Dh{24D^M`91D@@y0!)S`paWFqIOzmL;9O)6$oGB zt{p0y0ysT0E=FXv-lbJP%sh~}?m<2Fu?{I`x!BDF;9+h#r>g^?KG3G*adv(1l}Pqn zkV*{A3+nt{yZmkI-|G~tmfmf9WjzaY@5OPVdlgU?=L-QEvYmZT6+eSmy`Fv5cpE=? zYdz6zm>pa9Jp&WLh$zO>NnDDW{W`$YMNt@ci}J(IzxpDUv+}*hZ8?vM@~}}v`Fzw->I;wi0`6v7;scG( z*u1s*vz4u^NDEOmqbdEF9jBoPX)xcHvtq^XTtU`nG4b|DoX!@|K03KQRLK&=Fxu(g z_}N^F5WlrfRHqXP@@!9xBe9lMJc?P%$+igm?yZG#yj=vo*M!}Y!!|L^<1-b33;GRm z1)%2FJrJG(d{;X<)R^&kBoC|qoV%u**{nR$Z!wO*y&bt7rIaHXutiP++%m6h)5Iv> z@&4gREKAxtNG3%Vrs5qSR>uHGj>mZ%X)$Mi8!0_Ma1jC=x8UAxkab`Z{~1o%d<&Sv`Govp=tm>5wCa1ku3CsNQZ zkB$I%n!hCg{NCs7J|ZsN8_=T@?RYcd#h}pOqhM)F2wh_HxB)0n)pF^h)Y2%t z2QN8O5-H~-Z%9Q4!A)}~x}E3U1yEbiYBt>NaN9*=0Xj8%gW6Bo(MNs4VntcH){RSg zsy>9}r0<&H?M9KYcQ2uKf|^fAcI=uBOdmvYF{`?X*#Z7;dn3=?NudCt;dcOhkT=y9 z8Pa1=@mK(FFkUOI>%u_z{s3RpnEM0#bmV|n1#o3kpf2)qaN^|Yg-rpzPDy}QV<}tk zTtX(^`<6QgNb)JbuVy>+*yazfW9t{OEr5G=>y!dGzkHs#w`|wjZ3Fn;WAuUCfoyU( z_J5C><^6Kvc%AZHhh|0K`!yZ8C$gnG+J|?ZL`*kHEpD0FQsmF+8ULCjw8BA0GhV%8dY5 zrU3u+t9w0(;({o=SnYMd)(|dx*-!<(|glTRtao ziCO!Fq?ER)b@r-KFo+G6hU}?YJlC>9u~e~C84Ck%C$Cpoj%lAFe{wvydQNf&lEYco z9ju!TH6I6Yeec#Gvo0W-=sKV+qTw=a_BO*#M>Hgy{Vfh{qXRWv2*+#UiHaIH*DRl? zwsNCP>a;JOZYW);R*&P? zzbciS;il$!z!Ze@$pr?vpIOh+Q+Y2l+zs3C7l!9@R*WN(rUKRwo`>pTbe4kLoHy{# zwFq&rqLr~V`Ro!X+~1WcwhQ8Vu);{6_cEi#;rAK50dNCwBX9}+E^?P5@Tv4@w(jvU z(NB0eq3uGbl%4cb|r_l5G`^-vZ#ly6MTn z=9>G>oRl5YUdFQLTDNf7KBE&}=5dcceR_S2=(+wvo(kZa+bO{P<@YpkvYyT^pH`~N zasmnE$=jB*cPWXZ;q$sddHwZG0FQspR&%4%)(>BP`Lo#%9~Qt-tyC>l)Gc)NIsmSw zWScH!{uKbf7dezy-*8RB5T+77_c}N=$OWNMMX()PbH}EjCby?CDV0(fza>r!m?HB= z>e{OStBOKVT}BMm#*^bEuBBS3-LOMSsn#R)*FCS5l4aE%j89emS|-KgLon{7K;Y{{ z!K5}Q_rc11-YA#1q9r8mK04>!d`Ye$+n(T|{Va3cE_{MPqwl6C-9krvH46n;PoBP1 zobFB8Gv4_D+0&bsE@x%gQYiH#=D%k(!a4sZ=b1tB$Ij!s8d|=5km^hBO#*O;Hbl8L z2QCKj9$C;7Ixdu-9 zCV)GUd*H6;sQ}K!?OKez@(RYFpRbnQ0~i>7T;O3~o^V|#qmI}4m++{)G7(+?udA8w z#`C}jej6^`JC)1pFxZzTcbX@_FW0b}IX^+}bpZTjW&>{C4RRk1p(BO=RiwAS-$PD6 zf#?szh#XH;!PKvRn0@!*%ipYKKYSAwtx%>N12rOpyGw&Y9kBuP@D8TB z4YuL4>-?*GpMvlffkSZe;PF6ah)-pX04x)+i;z{zz;Y?A(P{)xO*OqJZq)}VUkZk$ zT6MI>WfRv>Sht()MNTyLS9^bL!q@7Yep)D$)GTdL&?%^NT23vC+>=%QZs$>EV#oxO z5ptB08%{gT`;4-=XnY6@{fvysSyEm=!*5dGL2pXBh8C2@&rCum5T?L$2LW{fUi9vX z5y1_#QM4(gxzoLA(peZsRD7k{GxECw{NuYf3+1<$Q$>z}5_8}|$6)4OGa4fGy9(|S zrP8w5H#9Df(XyorgPpXo;r`%Js4Dgv13brY+}-^E4&^xD{%Tey3IV;Ps5Qtw&TXOM~c%fPrl&uaEA^GDJW;9qb3SNS2~8XJfX0pPkm{b4ra z`x$1oe#Qs^Zl?zj0bcU*$PnP~efsGaWWj^rnFfz9X>c|`$Yvo?ikLbqOD#lJm(r`H zU@o2uq{U)hli!_%O85%w{OH3WETcKwsrnd19mU3CsBXQgRwd~z(w3C#CZX3YyL5&l zJ(~o?A9u}h{&fvRSh_!Bk zEx^&GwvW@B3`bK4z(>;v@8*L%w-INd{4f86EN=ScYfaXY$t>-!XV<@qOg0L%d8k$j zN^N{oXD>hKeOcT8`>=7suPrLUYm+y|&KpsPex+GF)lT>4)@0_;^WK_-*JN9dc435^e|$ zpGl=S)aT`+#2w;VvPo-7zi>PwdH8Iqga_)WFlrhKPFmUc3g?=Jg4)@Qg^3>*DU>Hi zDU^)B@BA#3{{enilB*H86{RfUU21Ke-?G-$WV!lechw_30dUnQ zsj9VG0q`JrAp8r~wDQ#s^ra&)0>OM#P4);Q5cJA$T6@H>I)RIOTy zmZR{AnU+0Oe~v|cHD*f933gSwt)}ZHvDX7hop6_)a8gL1Fz1~IO93idu0zNKLvtgp zXowA|g*xWrCR|96nCFhFh1Ya@iazWQFR^#HK|5l)#T5*vi4!C_EQ%@g;+E8!*%rQM z)Nxtj>iiz3Q2q_;f&UNiyCMSoMtqM~g(`f1_dV&1?|&e9U1f7{#QQpHD^c-Wz!`b$HYo?s;oKj(2hF28MvRBrP z9Kmm&*b&d(xuk8yDBkc4k><9H5W0JD*i?N-CpXEAmdTh{2I5$rpt>j~2LK)!*@Q0$m1h|2B34E8Xc`7sRNnTYztR77t zMf0dNIv(k=p{J)!shMV0cJY zip#S1fYkzHnMt#Pq$*%~a-d*XrpQb$vahf&y3y&$jPj)Q47zacog<#^zw~6wov`ou z8dXtvRTV;2Y-igvUM(5osH518^>=WH)suwY5VgQeynh zJC~Xz6D+l@T47rsC(nZ25!SMzk}z{9ha7?5#sCzG{@tgI-=D{-gkMvNlEo+-7sis=D4AG*P1Y18HT4w; zJuccc9h{uOsGXXO$FVrK4%g(D;ka!RUeIM)eyT65W-!waJE4yi2C@@6 z0j(#vn}r=_G%Adf=yF{gC(K|NafPPB_RPEFtQyc!6z>?Xj5zB2=E2Nb)?cj5xM;4^jg3>-oq41{IBjXs!&E9gbLqV;df=cl{C%B_`lP$1ANf{{^{qRkp?FQ z|MK(%hPNQR)Cda{iOZ#<2c0gFst-d+Lx##}w`^gWldDGqSi4X3+6vGZ!)69X2>#(< z7&GDP-y|#z?Bu00G(8+tY4pJw=ipV7%Db(axb4h~is&@`uUP6mWzl6{J^*D+xQ}2W zwo#+!@+UN|_#H2+1hG-OE}HNVYR8DNH*YJyg?L!t{mAkZK4KHW@Y}vr*Quut`Rohn z>$0FwcVCO}osaTKObLE~H!(*&RBi2XQ_-*&FWyiA8#BqH5a$eZ5fK^ zJ=DSd%)UHr1mV`25PC<`6DV!NV|VG{1cTu@EvJ|lE%SV$(?}k~TSVAmZ9WLRhey_< zn9|eRg=eF24CSsn-AHhAAZ;dOaY~hghZ?_!b>Opu%z=-3n7WhQ^|WIz0g32#_Qmn2 zQU6Y-7m4ty63=x~YAx~Hp;+ZMYDdk2-qCW-HB?@2_y)wF{dENXEjEGHaT4N2-YwR4b|nYlrzbKTi2G1eWl`FC=!lKsvW^fkBTS14TV)}F-%uDj6NU?C(=f0Z zMl)FtsT3}V}JNVi+Y-(IYzUErpeZ)`^3hqEjFsKb!>R@>=s z@lEW#xcTXu7e>oQIrK70wSVLCpd>Z52O zc}$ns(U@3iu*PSp3h!im%fVn0s|TpLYuO2owHb3`m>eIhAiG&HNGFG!gp-CW3O6!c zPIhxKtMMl0s9o_p8u5KMzn=uYm*2iHYSb?D<75>GZv+oX@Vc3qA@osS1>9w;$bZ|- zgt&BWxo$HmG$gtKxSL_Rtd&GZbiz{t-ds|v5z@%u+_M4ErXj>JIhx%1(mmCB2}-AV z_}r1_HL6go10MlCYHu0vA-{X>`;!shqjsxr1L3EeX%rbAa*f$ws}xu6P@nbRtw&f5 zu(6tl9w&$$F4{?{-1&SFAIEs))U$M**W-Gl4MLDwcn+g;% z-z;wxZI*)fni5D(&-xP|jtBZ~Wz0(_w?*BcRTBkNHa5 zFS94UTRtT-DCx@OOn`Oc`s?|84@Opmlji0n#9f>a+rT^K;$o_h2IE?_POOf>a#s3` zyKU;GW{eA#7RUWJ@j?z)+83!&JjnA z!fR>StRtihg%o%_iFqjNfscCNx-0+9O5D<$^!IA4JQ=koeY+qAw;JWWj__Pt7Oon0 zzfxr=8;zK@C_Lm-J+Ad_>Vz>mShUQxY>-z6cmqEXxM^_J7L?U;fFaW=%u;wesC)4s zwr>^7piqWVJF1OUD1WZ$+3NlYv$KW9zI1*z+vqK#_uy3pD3qXV<-cbmzenv+zbuYV z6XBiUJqB;&6}-72X0Tdz19Wg5qjZ?&HV+|q?h#kE)lNbT$?e>0tvN+`^YyYo1MWIF zX{pM3I|Ex^(K{P??w-bPY;0=$@j>m#Gvw#FhB{lWlDqS(d9hC4?bX%a#UHzs-8byU zXM5Fhupj&ty=TyL_;=-ec~-xKP99o70h%nv)jKRGObuhx_00JjaN)8%@C)>2EI<73a`(sX(c z0jK{Q;IrZ9KHIoox(k52i{|y_7xwP$q-j1JL$|w4p>Pm@Z)Cuy^83li?@@c!ggcoh zjo?Uv6MnNcIGlys6oI>(RX|;i!}h+PPG2z`6KDfFs1a-JqdV+md7d9N?GB`>$F58HBH|b_4L+^?CsCD*>Kc*)J~rrK$M6%%*3Tlf~|z z;VFJs{QdZgaEG;Z-_p7(ZNh6tCB44FHIBS@iGwcf%RI;PV)A=)IoU%4 z`1E>meG7|jk@W zuU<@^MypoZ3nuJD!}=4oc>{B-9~`xREeC0EwmS~J@(K*kYVh0+lSKDX_~>(B^YlCi zDe^2sF~TU~iu^Whi(Uo2x# zzcUv@fa@o@+y>6CCpTwjt3k#Yci8jQG-h5;t{7KN;>-Ec>-f@!az8BrZuWiva9!K? zHqW^y@bqR{KyLpbnbGCyRdWdK!3VA<(_JP|Xa*I8$?wOPqmdRpVDv$g&BD#brjYwm^1%Qj(=|D?L8e&E~A>y`?c4kPO8^-O@r zw0Zr^^@pw4z5{i750@aECcwWqp1d$>)ZX0ZXj z4b@^-;|A~p+the5Sr;}nmWAs@`5n8Qb$bdiC2a>)(EfThn>F9fdBn7)%m-sobsd5D z`P0eL@0dqaZf5}S+vTF&Qst8QWbU<*YmQ4{Dv|tx_;}UEZy8t9H3PG=C1LmE#(^_F zZL#J;$j?t_LO%h1-agHQetybt?43)i6G0G#eW17yAE*lze4q$|u8iVh6p6&d1R(~p zivRz2Sf{^gk8O(zYV<Sv=7c3i*6$f7lrH zXC?4n0e__#%*~l$!~VW~Bof6UO0MTQbr&C8emDL#GnbmvlxJO&4c2s4Dstb3+&c|{ zoViQfbOHydV(&U06fT)I%tCAD?49V>p7#UIRyCwsNyqS6*t1@7t@|JXn7% zm|BkWy!IO&-(Q~_S?;gh+|yIwuF}~rq6!0;FIqVq5ip-qI zyjKa%_B^6C39SeQ7pRFN6pf=&LkLMMi{2gK-%e+xKQSI`BZ7l{aug!4fZv3)W4%d> zQo1wsySjdA2z|^7vj?nux0*hSn=GOoo9&y!WX5RsL zUT~fx?Z)@e-829{NS#1l4xf4Vx*cWTnOYBUzT1XIemr>mnNB?!>F2dTWPpdT6js<5Ehf;GoD_rk5Bb^nN8`I-p zV=U%jrM-Dh7$Kp9DWX_MfzSp!!QfaUnJ_R4hZIWtE)vS+i_Bq`R49*A13v*03eK-d zXMnGTE_Y1R>+QlKG4pmk!0pw8zt=s$=LUF;TrOnuYaMi7@82vIhX&xGl^2t+B62QY zb^s?dd;W6rwNk=~t1SVze_nX}A|x$jYaY&%klSl(KfiBQ0WR@pJ#7z2OMegG2;de_ z?0^pieAuyUaqEsO0j|{GcQdCId#S~d46VRB5PG==;9JK^^ zOJq?c^%MXOrs25W-r`v2#yDaLEhr>WK=LMzP`zX$6u-M2d2amolbA`{gOJz&d{1cv zS`%qOyoT02z&ZIDz~^b-c;M4AFuYuT3-CI6(iJsi(*PWs7EFu!>1TO0UB{y0XV=j;Nvy{ zzDi{-$`rljloN(>NuG9}&Wa%KLhNW2xAC~+Ojk@jwdn{!wu;7F1=x_BLI>P% zrZB<3@O|#ww*WsO*$73Z*A>hL_#9a70&Vo!X~lLs%0uU6TTnmz6yP4v0$i(vItj&h zH>npns_lq%G>5zvoQ3Dr1MdOO!`YBk^zObJtk;i;`~5xmEkqUi<9SdpJK%4}6y~ra z85&9uoXoOlC@ja+yX@qUdBMNhqh4H3OlQV#KrQFG?p@5X9~l+JoX{6142g#5GNJLe zk+scily0$FXO+~zn@LN(Jb6jCOxvkNEMedq4dnz$C?fH!P^<;`a{d#5_i^CXz>Ub+ zj{(k9VGr=v>g^^m`_+w;)RYe3XdA1%mLGec0DqHF?uQESi7#PGDD(N|`4@9c9LEp$ zbNbZk`R|)I!c2VI0iSMtI5TY6k!%wpcv!a{^Hc^BX7IPuS(N}39aPi$9@1G!+h%=z zPEqQt3`ImaRmn{s2#=vo{tcb+#8p-`oKB}9Ow_~g9ASi0EuEJ`M8N@n{AEX;lakCT z@HYpv26&58*ATv^USshbAh2UNrTaR7uLuY40iL7h1~}7?J!PjpVvn)gyq51-HSk?^ z%K^CDW{vJ1WWzjwzmpAg&lGoP4}9`KeX~F2fS>LJlo5k6?D+Oc9=HH*f)@`Q4Mm79 zSPqhBI;%^$e{p6TWOp^8S?zVrJaznSy>PyX0Eg_#m!f00&a!pDRc3D;h$GHW{BYt2 z)#15JZHY-X!m!G3T=4%tjIh8`DEkq>SBsT!wci!mEr8ct*j(+*IPq*so~@_v_O$?K z2MHF4k?EuzlWiaB)r`D_jE*@3uG68t# zE|Wf_5ee{>g3}c^o-RIjTRJGE131;CE~P8$G4?v<&%}zKp5M1_CpGh1X;40%88+-F zh6oP9i9->;F&1Nf<$epfs^Mp|Pa4}{o+acP>086VGhCNIp8e2VH5T~9K?~=d$qI^B zaKrK-bm>r~_IvkB+L_m^yJvEC`EoQl3Z+OW&rifc9*A>o^+SLQb|D$Wd!O=n0lyl= zL|Y(ozSGLZgH8g9U0(xmqz+w072grc&f`8W3kGL%eYV~*MlZ@E?R1N+`>{zK^L(zQ zI;KFY9)G?{fN$q|x+c(j!(&kK6Tkckz^9^x@A!dPpcqa5zx=s{m~9&H0KVF6TzO5#5LdShSudB1{R}Djum@sq zmTS_On7-@fVk#$#<$A;Lwr{rCQ?2Fpjj!xGYV>+Pn{8fyxl@!hFXSxqIc`C}ldCNK zTrcBv7HvGvex?3B<6m?84dt|7*H8ILHj|0HX*}u~)qIRC;}3iK^i5ZPeics!ft%lB z4s+O1O#pw8i+RLvx!?uiG9pm)K1+y+q6SMCxC(K)6!O4HXT>@l@0wO;F44g$vR}PO zD9&9)0m0>&!{yqRYIr?5R@|#EIThP4D-6ZCP?}AR|9=T(*#B(ZW@?0>6oQX+^uvy7 zD*%oNp8FdIy}yVk=-~0@7=bCu=3Ulg5^0h}i8Db#soU|8&Z;!hj+&w(O&dCZZ_(N- z=}QX@aJpI*Rdn<^9y1&x7s}Pb@UJ#>I6+Y;!~SP$)?v0Wgm0%?>odcK9nH1?Jabxo zwL9j0qELt;j6X3r(-CGFMJnNXqohdq7WA38Wa&I~XL-FRGs!w@HiPY6H@TWM8N!L@ zD&aYAS``Z(r8aa35!{oQT8epMw^SSUUsp#G{t1FbsJ<2CdGHzzdWhO?hTM4?;^e4n&* zvcvv!>oXC~2Ed2?3p2nWI376szF$_$BcN~$O-1z}ua{tQxI`mfxu7mSmnH^ART!tp zTqt!*wbr0e1o889s7;3K4bBxnsIy}1_Ry-{9KFeLGTXck zBQasFsBdzi&>zhJ{-QR;bQB5%XIJ{+fe-sL4mc9{Yyf=N@y!57L#eZvcWYAcQ-lKw z-5i6zanwmB2xnnVR8)xS{N<}G=^+iF1HxmpOgz6*G;rcjFwo7*&D1%ox`{=VN_I4G z6`U0f{Bo1b>iGcpu-`YY!~AA1%mMIW$29}I&4+TK034?}-hphN1%`!eVHr;6!g|E+ z3_elS8}MjwZlk8U5bj(kY4F(dmhPaLcR7IQGfhxzLL0Qj(DSr2e8r$+@3?&h{UDx{*|faiQB*=0#*74ar#FV#$ih~c@N8b#1v zhcEHr(Rd|up~$?LUWNk>$=h^RZK3wBgcRkIbzgYk;&<<9MrA@YVoF4Ep7DmmnRuBb}%K z+-NA(9ghx`iTpT|SpzQ)_=(w4?K694_tUr$MR8c9st;95<+f_ImHL7zwNj-%xRsH- zuGbLa#sa(+7O)X$l>|lM1$f%0?cd&e=FIfQon0pZxSMrV5iAg6$WjIw;c3CJ^@l?^TAY#wSR;5y3Y zKz*DD7h{!KdVWhb=YdlZccCUu=0^LRz`~tQ-f8IIB)LgOC==>!K3TgYW(V-j?!yDe z=6T>dod@38M>O99|JJU{!+cguXGO;w%yyIKMNXkA4zo+BS?^km3Bj?+RZ^vIFBw+Y zOlP&G?$$fvu$vuGk92uE94!f1337tiyA+HH8DGT%x3XD$L|i{ojr&V;9?4JD}sND zY$!h%1%;VU%pX>q-$3pY;#=VCGN6FhE*b@8O=m@P(=SIC797B>%&o63Ew)+Zr?X;< znW`gujYVOvIj#%9O*TS7IK#lZD3s3bdpR}QoCJOcfWLZucz{Ltv%RBdoprVn!2QYe z2H-+j1fh`PaKjzUA$xOG2g=(_XJtbQ4XdRWj;yfhtl*oAvC25_j6zX{JDIzI@MbzI zT|cd1yzn3YTQ*tG!Q})0NhOr8|3?~l9>pK$s>K#XcK?_yj7siXew@0f<3)EViku3# zC!yRL;I9tTGMObY?h?o8akr>XXG;V8R{~s2S2ku!S;!^CxAD2f|7JQXA{-pHjyL!Y zm`g1=?V?Z6hnZ8%8S8Nw&fQdc^}=lc3ikx^7NM-^taxN^C!GM=_9K<@hbCy?-#HEZ z$0wBwrHsNQaN7%7fO-3?(&6fa4+8F&|JNgqax!16%)RG!y|Q~*L?`a|xN;o#ElZJ# z)n!(<+$xhUnYSM1odEu7zf6q9JHqeT>|%Cx*ty}ItsMPDZh~MhPx7 zKH#~{Hi(j&c@Kz7g=s)PlH3G^I~g1W#)0cB9e?Q^v$8QYZFRMIaHc7)v=2zmMKR|* z@SmR!z_TE3!);4j+=3pxYQf-TI}L9Oa2;G1UJhtLrarf~V)I=s34(ATz|mCYaUK^z zziq9%G)|H^35EQ=3&8iHWG3{U%}&eu1o)oiv+1~78o9IOJ#aBUot05OHr$*Ym;t)= zt5Nt|(^s;IE@`l$yw~I-^iK&JO_?6jP%JJqz9*rqvrLO6qvoL9lC{z=Ie#pAqwTz0Ixp`@JIoEHx}MAz-_7W0Dj-L7~n-vFL4hBfh)o92JoX3;degE(<`y@ z+4c4L!P}G3Y$OC9cQQ(6t9js>&Waj1wQu8IH_}Z6|8IaNQ5e>}Ug2u< zw3kL`W2xx%>e!rRK^;+CM*qn5XH$wMTopzs@5PR|+7sZ*Qd>A;ugG->9oGXmzg}sT z?pPe!Ufl|CSQW$`z^Rn;E_zQI>+qAA7e$F&nD95p z%`iTHdtMY&qS*uAFHWR#8~i@4PYu9N>vCm)?;7=cmXzh};^Ok+Vl+L!zCM^tr<3WV zBY0=G^S~KLh?L+6LxFNlYHx6jdQjZV>~XPy1hd(|b)Z?(S-JO@EEsouQ*Pl_zZdE zV`OTxyjOvG+p&t~Ro)o;Dq)LZ^?;YBb3?>vM!N^_9=~3QhYN}b-4_Bp zdq2SQpl|jy?}-A8b$$LM_EAFvZPsP68NofXqFVu;x(A_YO(7Q+=10zpz(_ku#s&;3 z#&?)?#Y!Rgc2y<9@0ah6U#<-B=+EtK5m2I&^5T+qF?w_Ub~2evWgB-YN@uGBoU`c} z2filIrpJxLh6(Tn%w-uLR)aw~GUROsmoZ8|7kvSHpZg6rd#38_E!_Flm_9A)1E5 z@^~2JaU4RZ&J55i%Gqox<2W{W?dNbMgV4UW1I4Sj0dVZARJ`XR$jpcXsY{AjhtP5V z4e%75EZZ0H<9{MpqNs^|Q`8TN>bhSb`%8#Yia3g@VoQ){uWt4)W0j=Y{P|e*(ax zoij5H+!<@a*l<-&U=+la-`Mlxl#)hNVPiQSZmwRCO@=d_m1eU7z#GY|etUcqdFUf9 z1UMbc)KITajwJ+m;}XIb$C9pkp8w7t>8x<#rLzL4Z8|GffX&#}z;m>-#~S)@uFobX)-B$vE9mE=6NnPKb!T#E{DWg?^+Vs-(*`~7|fai$gEe2f?+ znAY)o@&QnYY{$w)ZD(;Cz{{ZbVSsZ0ekuM`yjln@qw^)EJf#3Usj4z~_YyBLe^~|K zr}6d-meeBvTz&9zY)qu^MIy?9;M&nszTcr97TfECgMFSI9vmF6LXD&`#F32H5&VJ7 z2e_oOk~qRo4Zk;B9$EYW=Ex8Z4Fx>6vnd%tNM(faCERR-rn5qUB_^2NlpT=mrL*Fz z%Z3xK=yod_YdWiDAPRI8PNlaFcylWKW7EK^co4FD?- z;I#rg`KM#$0X(tYbK@Zgy?nk|$87=}7xa6uAw}c?jBxOij_XVQ}&ZbH->+U9Kc0WVi1b-LKI3A93R&^t9szUp9#WEMi)sr z5`wD_KAQ=>&(Fu=hcDpjuKB_|Um4(s^6Wg5=T2`A@ZApJ4`M#RL2%W;`4ZF3oQImi zXcNAtbm^?z8%-;Yz}d!`TaHcBS-~`&6>hdkfRoo6G^j#2c@1QvNMk}mr<62fQM8QW z7aD}}cxPhPc;&VNz+*y9+mcKK@_xUX2Y4w0F|Gd|fJX}O935%CFVF703@=f$Xdd9X z9oGlAyJfu;z$rH`0C>*_IO=?W$Bi2mwv!L=FqP-&_lM|E1Y(&F@PgM$o3+?UDERgS zcpVJPM4tU1V=9zYJn$%)iH|+G8qH<^_~h*RW;(r@2*PLQu<;e&W41CF_L_x<061@y z9z5&p0ZaidW*uKlg5FI$#u`?J@8aK@M2XDd>7%~lgatpt8zBF7~Nc7|Ml06Y}X?Kd_0B=zS}Os z2zl4niO-J-@XqCaEcPA^93D6|6oPmzot2oM&I;AgOZZ-r1^DYkIH&P6a)#qz0}Y#j zgF1!Y)nT(`wsTYa($&~jn-2wqm$UDj1pd`yeTnIt3cC~q)RQE;g(!3SKy90bid|~J zO8}hrS_9?fn3^+I-ZeffpL0-URr% zYTyHE;K956UGQ>eH4pr7G8)ON%C~3dH^T3!@ca7u;^wc{*Eg55@%fuK=VNisNBavD zlmr{jJ%Aq_B+``b;(U-KM|;uE`VNXM07swoQ^I*aNg{jgd}F#xfFDP;rxcUqwK*I- z!LuFU)z+S+AuK!ud)IeV*;@UZ<>dT7y{L$nSUvpb&->@} zrI$9|5+X?E97|PszGls{M9=CgI z4)$+&S{V#bF3ogJDCbUTqi3A8$zntBp;#TSg1b9!d+XSeU#c@6Z`*m~DL$GLp1(ei5T-6B3m+!^#{ZH`9M>-5Ow&hB zwKN>)?)}us*9FI)GBT~g=cDfb9GXY0)L~8_09v%dAO9l0hD8%}A;9Bh(~2eexURsK zv9waHG#d?Y`$018M26s{rV@dA{n5YS`Dq~+_TcG2nf8+QHG}_A8~0sJ2Hq8aQF1&o zCpTA9=~n#oxoSqkkZ&j+Jju@Y{`zfan#a>vuQ4vfdgu|eN`oIdVJgDi0DiU)Es>>jlR7r4?i1JmP z@cSOgzJRjz1t~Qr>lgJex7hGG|FN8N?(6N?4al#aW6JtQsJ6Yr3mcYC_r+3B%Lz4U z#;bZGsJU+$Ezm9212rX4!_wyLp~fC~-h;c(Vb1g{AzWg+>Wp2+bjMc!x}E1o9z|X; zJn}Sy#4Ib5sk?F;)AsU1h{`ik+&R)9d{b@GuN&gyWfz@;%y!rqi+p60SCSw zIUmQVk>i5!?&_ocRp<%WEbS z3QMy+C=DteJ`n8VjC^t`I2(=2FMca^cRs)N1Hhb}y4;kbQ3HDnv_2E3xtcBwLz?o$ z0))(wwCnR6+i962&!wNV9h|(iqm%Z`U^?md0qkmhMFu=W+3+Md1$%%1NdOrBq6T4z zgHUXzvlV8Oq7Ss*+NXp2&reZ`AL(i#Dy3&;2!#3GetlKb@4Vqy?ZcXFAhITSO1@*| z^Lhh+mo~|>uS7*SqcLTr!-9W2u&U?_Y>(8rAoyXmzz7vnhu3(aWXV`t&ef^RHo3s0 z$F~slB5MsS0yn)HMwSd(TTQ;K#cHc@s5<}JM3;c4uZ1O~5A>ms_r*r5nwNh0%nJMH zs)ofR5E$X(s*W`gpY)qZS5UrxrFcVe429|7jtmYEYNg>*=_Hy-@!xUahTWEcbHwy5 z150qJ8vbZ{;NRZa_+g`P$^VRVIGV?Qz-7;6Ui+1s7BJ@FFN6|xCkn9UO7bhnVT^r} zht*SyjjVN0@3mv_@4pIWr-=o~Wv|MMod(sD_C4M?`?`7p@@uf#NMqcUVMORFQqd>= z^;n2=O$_-Qeeq1^mw+h+x-N|IDh zh)%Y0cKy5IGu66f8STR3>hy0p=~~~5Q;b%muRI|++IP(=N3<$e!&c4uqX48$NTe?O zjVaejJWZ6FGMNwt0rhJHZi(cl8$1aR2t@z*DOlZ%VCJn;stDi3MKO+fHjyFD!?7h; z`~YE;J$PY$euVHMkBJ6ku`c1=5NTwgq`QE7Fh*M{-0Y(-_k1K%oXKC}b)!wUSz(9A zkM8NDghGqrAio8_1IP%N%WvvGA|0<-ZJrMWL_^_{Fvlmnc(=6rtF3LV7uoj z5%bGPj1XaI-Mub#iZ==qur)LY80(IFPh44{r=N}06>KIlLGr?{1cc@wh%Cue1*{k* zr`shU`&9-A2v*5`#i(Trli|#x#*V6M6x53H8pbH>EB+B<5u9IiqSGIrxR1xb(ipH^ zFG{`aenQ-%DAxC(3DJ1*I+jgCrXq_0(K+mFR2QSxUdqULASTzz^RK*7- z><5=O=s1SYaovpnPPPvuV6F#mY%vnr7Y=spAQAS@w^yY*5i{;P^J z%21Jzpvu|thg7j81GY)1T)6Y@OOWX)Mp93h{arQ6LwaLI{r_p*qGia_tlyK`t-EY2?mVY2k zSc$wEU6+Z_8Z~ZDg$-@u1^$eA)on8ICxSIeLiVd+qrZYVWnj6o<0cMyK(O{$f1<+h z5_>t5e^YO7d<=LQ`_6m4Yvr`Tu+|i`{%u;!U%2zfv9CxzZC~*Um8kP(AgWD=Me;3= zeodiKm4f!cIGd5*cGbbTf)qctUfvMl)Pxbeg}84gxez7!P6#x*savG`kKw4X^6l-Q zC&WXO*nN;p0ga0SK8s9gmcQZKbKO9^wFBg@5@kcc^+B%d+);mrz2zRgbcN!|f&LdP zP*=K0@;KwS<9sg%UxKC|$%Q48TT(Z=ZKU|CDSY2gFJHLOjv6o8Hs{QeiEvwEHZS!1 z9h`v<^rgnFWPcyFRNf9yF0JDXpWaeDl8ScfG(0pu?`sYG7=tgze4gE3Ow}w%Dc|;d z)kv&T&2O!8GGRB@1rBv312Rzr)FFDwz8rgHXZ~W;gS=!+*q5&52N~5W2pNme>Na;0B{Iiu;htFvAuT#YI$mn&n*f`M>~BOYY7|ihpr~!8Tsz zjVUEfDL&PVn6%Qop8(YEq&*W0C9D$4zOwpG+TEPc<%J6OLovTt>d#GlB1SW*#RJSY zRU?hX4~1pK#QgZ@TkC6hHoD{z>g8GT?$2|GW36HmO?1In^as|LwfST z2fk0f`{-v;z{iNY_i^%8e`!k93&047z2jl?xkEVM&yCh8)J!u>+?T}Zl?zeJ(lu#~ z($}b>Z-TNyY5BxO#U^tPd};&n)czkaQVR5yA`JJQm!`c8>5)>suyH{Cc%YrJZ_uD} z^~FJMRh27%X%k_?*BvLzYD>!Pk7&b^)>(EJC{*0{E0ADhc!@FLeQ_C-J-!;4f4j=R z$&m**;bLFOQG?jysx3C}r(Y&@CE^U^~xR@*PGA|A4vyIpPPr}`R6RxUDy z33feiyM2m>_}^pYorw|?VT4tqjcCmNf*L;jYs#5b)4=%60_u^{Yu1u-=s*06SGOlY zT_Kbs7ct5gO#iB$-Z4x&y^?t z=~!b^YP6#S9dmsDripIU_>-4#j@LKh*bwV7%ENnBhMw)*kLDJ?a-JamrE^_8C+|T9 zeUChmFOQtMCHZ6WTh5=N!&3!1*||$;m+LF`md37I<8xbuUl+t%N#=PsUn(#Z_7_JL zXA}ayhk|L7wCPErx*_+*Vr>UcwY>1O=OCE4pD08*xveunK)AG5sg$Rqr(?yfAhAI% z5xL!Fj&8nUL}_xYliB`wh*}k&r-i(!=UcbmuF|@DQ02z+xFSN3v@-cq=0;l1^+yl8 z5M?hegHR;dXC+T7oYnO-_U{B1ize;#<;~8+p7^s|Wk->z+{?AS@Rkn>4hWQXdk}HR zu`qTS*_JVWZ18jI9;bLkTN9Vu_TPVYtgQbra6}A4H|o;kWC)ks#-w|zyj zh_43S){WV%Z?KV+)qSAwJ;g#HLoOLHM0DCDOvEwcd2Y$xkdz*)%Y;s(3apYM()up6 zcQ-sgh~cV^?>4~|_(yq-c|GKa0NpT=@$|=By>wOc>22M3>1~(+lYPg#e}=o~fs#40 zThkIiG^O%F3{IE@ZGmX86yu}CD*cbgwtJt9Bxa$*%fW|wyu3mkG-42DlNHPcV6EDN72sZXD{%q7g2$>7*GEEB zEcT-$CvMpCA}E7VqeT_4)4Qk&&r5M6@Y2)@PK%Cmp!Dmj{>rR^%r9UaU+h??!aDAH>LG36~ zhEP>_SRfv`x(H#Rr*5*rGaAKrjv>;-X1+}N0p^r*PUA0!zIgl9fNv8*Y5rofnCr)Y@qt*28fsd5oRgNa3H(*#~8=sZW z9?v(Bt(BJ_?~a21dN&qL0d|een^52TEQQ)HQU~f7E}oi1rizO_7=g9tJ!R-R{D;|3Cct?KcS5MRQQ1S!Y-|Xk9 z52~TyhkeCffeii}s96H^1m()Np z?zGS1Va3t0J9f-NvHirxqQuXOqGaoA=0pI*HA5X-|zz&rMKg>fc6D||#K_3t-Z=cnuijxe^8if91CAXgY7 z{6|~j?%wZ?J)EvYn(@uz$gAoXAE2c~XyL|0ywGEW@I`DC-WoD}?Iq<)_bt-*YUu75 zZ0;9zr{ol3EAGVkgT7BAQnPQI`!99SRGZqdvclT$^z#>pQ|dB}v%2 z4@T|QWBR?S-wfWrtYef!l-9^yVrel~|JOi*Ha_4#&Iuw1eW+J2dg9Jyv2#+0#6lfT zcP2b>>%q>LVCx0$`;u@Ixi_vwfl2Q+!YvOtt9!{CJJaL_zoKL*gdC_sI6j&(S}aFv zzTm8YyH-A5sObm#1qQu~RV^Tu$^5AK!N$}M1`3t5OCDd=VE20=q(iDsfGKu&8CyuM0=P#vDllFjf=&;%?{YSG*`3lGcbava9e*0{|6 z)(po`deMu3-5YM0s7l3vI`soc+VC&{uNr;?GD&_y`gGy(wXRRN(`-m1NYJ++#CUG) z)GQPiodrWPg)P2JY7mV0@iF5dVlES z%-p>51!s_iN(0+XLz4|;K zAH8#749M?le%gH)9-Wbiq(wBR3}s9KxlM7QMK7rh`Et{xtLXejXFjs{P(;0_ufZW` z6KZ7EhARK1x}8vEzl*qfC1>B$Z=!ljJyWiCeiyMYv=QL_J?u5T^qnp7m2m`_%IlV> z7rV92C=4O!G~y@`i$UrJgqUxB3ZFE?pBzg3Gcp_$kE{tl^iMo8n zh3N&kny)#Nx;G3zdH1yaYwoL&Sa4P3?5lh>jbD5hMLKx0aM2S|3?@MBjTghfhj3zn z?+i}5UviM$V1Zb(tuC$0+JkG}#+}n|SUtVudKa;D{;94iMFtyAw+eu;A*srP-dSWE z6sv1^jSCSJWL;Q5dNht5n5t;h-H%c8Ubka+I^~*PS`X&9qYA3=$mQoTV{K$DY&NYb z22cQBQkF_l!6dCHUsJhpmEZMWhG>t8j;dttj`qF2fTu3+R7<*vAb|k6WWvAV#iCJ$ zlTHRYcxc#Zh>#B%)G0nweneo-tN@XccSuV=D6$W+4*LLlTTB0 z$2oNOy+9=&Q=qj(X~bY@4IN-&ersb`oS(n9SS+ub@uv#yg?=0XftJ`DZQi@)(C233gU&f4%%a-?}3 zBb@gqsNG!u!*M)+VOi6N1DNC^%Df)&?KHagq5`XZz4XDFzzuNi4I^=PD;eU&{_zR= zL)_I&Qd;!CN><~4ae*fyd6zGOb36URzSh!Bn&!7foD?gi2|sx9?!>*}F?g3o^Sf-+ zIxU?DV2E^Rnl2wx`QttwKo-3rJXE4Asdbi*XuGc@T2w!JcmvPKN?i1n7SCFIYgFzkB>k49(#3ruq(|UQQp@-X^s?6AWV@nDps^cTt3Q3$;kf5~>67)g z4LT}7Pyg;ii#VBfv6NpCdbJgr-~LXXfYTp!2yd)NVK8{U{Lbwq_lroH%okX>p_6gs zdm4rkx#f@sbd>Vs5WasNXFO0GY^D`8ORPjUSxLfOA#0a^OCn9@_)32Ai@gUS5F_3RDYbrx6jvgE5 zq+Gupk|kN$1g##~*AuHq^UGLdtq0@mxk>XoVmccbuQ126(ZQ!MJP3Pg!7UY8kDCo} zM`=DIk-hu*_#lbB!kN~%pgV4clyDA9+P!EJrM%#zyhK2*Dl*% zyjNIq5ue}>Q>qcVtB*WY4rGw8ZTNkjWh$R50Fdx^=H%jPUtWP>V;7gB>s=uJi&%%c zx(dIijX90DUCrEDOB+uFXM>NdGmFIgUti`%rD*Klx;$jQS__ zeL5OXTOZWt(d9Aos?Cvynd|+ddkJB{S|KCeRTYR;kU&q$O7sZ>2I}Qk9OelBP^dWv zR)95>#kytzdS+>pn+0vz4ZmpSiSKujxF_ngg75^{L8E*N>syF0&$^cvN(1iAZI&x-VlGZzsSz<)(32=Tb2kb?t z#F|*wnf7oS7vYKRi$S5~%}+<$S>(6i1--Z1KO`%U)_IDlby%HNGS#JFqGxjEIL}xuK%y zA7plOp(WgnB?X8SuIEs_pdX?(+MdS;zmK#0h!;G?j2ACTe%yw}@VbSr`k+SEmA9R( z1-vdTfF&143@CX?L`+cp(NlMY2`PjuQ%J~-q};(<48uOtgfHCdu*K3MMJtFTlr z+aXr_K{I@jrsARKTM^P)s=~0a+Rl&lNIm%8J`k&IYCWe1mZQrj31)>Jl64 zVtVupg&|5K4EQSXh!9u%@E`bV78`s=NtTtN#U||l0=36OCr6!FF3q$pbT7Z-?Zydy zWRmnCpGXj`@q=6P^=oYxxKP?sj558t_SgkP-<--|=ip}i#X^YjSA?{AaMg2PhABI4RVl5T zO~mSZoPnxGTXNE)HFXsV)Xnst-W7}*2jsoeS2{Lofbc_4XWE$HQ{fAIb54BCf&zt4YNMbheT za^yO??rx5i@!Y@nJ_dpx=w|yF0l=J#h`+~Iom^N9u@CO96a-ScHjJXbMB5%Ggv=yN z--Wpp2@RdbfzWYrs`FoXr(-YgFHTNUqAe1ckKN{=;|}9nebRELKK&A%bFqcT#Zi8S zoAqk^KOVUf=g2}*h0T#^>RY*Ic`E&&os$|SFNIV|_Bb77y7$D3_37G)6 z2Rk$#-gsb3zUzKjHGYkq6yjeOr@GG76q13Jvpjr=?VhlixD4n$Xm9+fsm`ttN&6w} zU5?uU{)>*^evANE+bQ^szKBi3gJ6TP-?1`6+5f?3Pvc00)z7$pGhfPN6mZCN)froW zI09k^q%>l8didpOOc03i8zZDFB)<7d@msHZa}Yk^I2Gm{{)1D;xS^9}5n&9<{|1Rq zX$vh8NQ;2oQj&BnOozp&J-zEWK?wKWewoJX;DTiG zFg@)n50;ULfgSo(+h7-;2Pmb7uB6^JKvaij9J*_T`X%$89ihdlzobn@fA_Ysce8n< z^IUNa3|V6}Yu7Trrgk&y=!ms6o$T${Swv1)-TPdL9fl#}N_)!dxeIbq@tC%&(i9x1 zyn6mk?%ks}yIjJ-l6wz7rZ8RoB%kwL1sNrpzV97dwvF2|8xqQQE?WDqa}${ihb=5S zL~CS4BGJK|JAa>`2J;eudDR2pF~)!gc5%5K(xG1GepwM@>Y!%Arwe;_F@D!k<6JN= z^!z_Kb>0V)wq1C?S92!OR+U!E#asX=pILfhT})P&hcoF*gruZa+XN-6|7(Tc)?;nl zJ0gNxyuJuu3I$$3?>GVC(l!7zh)&2%hdEWb`@&!3d8af^9iGjhJRuPye#6G~PxBKA z`dFZYiD2F?M&5%w@g2fF-iny{49xU;X*_`&PwVO6Gzf#MK5z^|J&>Okx;VvC!H-5k z1y3=dF9Y0{ou28znUpBBlaNF8QV#!DO9e4Y5DS!w7!Dpb&aKN9K@shaoupA(nEs)R zu=SaHK1sGqsotRE9v2>mWM)drajC}izJ9dysLmS%m1tl0L19KN&3 z%AxWN-DqF`0jnO4o_qe~{Isu@py}6^z3X+QCT2P>L%d=KJ#Ym3$6W7+4dLXA{X^3E zpmf=z9bHA^imgD!{;0tVSbrqAYKs+rFkUbKcZUO-KJ{o@vE>PYuvD;BsAiI2xT;6C zs}yjbK;%~fL$W>!2~hA1=eW=MjTjGO8G^PovyNh}uRpo$)wwtg{XD>`~l{3sXQLvr;EmVW|nb&N6f?z*Q zT61nl)yTseF1R1WbZp>Adv~_WagNQuGjH;?c0?i1`c?w@iCddJ?Ul>PquIpB9}fIK zq!`P?1d)J!Y2xjwt#JBj^9cgDd@yYxs?g6Q%8?uF^1o|F*EKpuZC@hbVK6bFz{Khq z;g5Mi( zbpVf^W2MDxd5Hv}{N%;mUAe1Rf%M6LapUPSPfg&BOC`&)H`Q55bT?m;sHb9%3Su0K6Q;? zm=+(W6o0t+x!D9kDCSqs=|r$SFVXh9!78PQ?R=cz=B(VgAL&B4ijYwgAu;gLeDfFa zAD)~4s$881`&6;^_<5A48(fzWUFEBz?<|2P)%Jx~BPMkQ1~P|ZuC>a?KA8heCQarc z(!U1-=Y}lovz>${+osGjs}6YwtgSIiAh~y&z6OGSrrj(wuS+V|oP6o_316(2o4aT) zOC?wDCX8F@j=wr^aIzG^4mNg`mKrqoKX8ELix)kghdgpe-U{IXTDgCF(j)fk^-1h& zhHCewf8JWu@X3TO8Fq8N%))sv6?5#(x%WL+42UsyLGBpyFyR=27~uK9|G1?pcQG%n zYwBt`!Dshz)2&h@qfwefDnbal&6jmkh@g{jO3zHuM>$tHP!~Q~PsH_cjVA_gSGIh;fJD4MS zV=6H|Yr1y)s61M$>t~L*l^i?WoOo26xj(`-*-=;ZZi=#ZqEBY#SmJMmL3L=;*h{|b z%2pRQthUQvxpd|>S`WmHwR6;p#sjoh9be|DaV#wo{^yF2J0j?&naI+F~(oL4SSy2h`svbIP*$2t+5XaI7iFk7oA&tCJZG@`;eAuzB-O$ zsYBHn&ghDUu4;1~hU47CxCp5e0eXDleiDp^0jdh?*-ms{RNFd3Vxt$zgkF6jlY_bC z;B-Zm%3<5fEC%G@1h)`vo4_2oJ0g=VXs^JCH7ImV$ln&lKvZX_@H@RFeHGnvCN6_{Hs zLx+@RqIGVsEhoJb`SR{lPWs2{KSEOc^9mxNs_(;`d|v}BPXiAp0I=}fR^HIAEG`p-dnwGHToTU&vwn?1=6)dN_#khHuR`-5Zr zF+BSDx!1BMWa)(l#;!+;eN~V9GuO!7E^dSZ*B+n3fl+qZ;`UnX8MRe;W z#h;}G70$zQZ){5*IzWTb(;cgyRLWRXh}}u|;Esl~UueRfAsAO=u+|K+*CQbO<6kFs z)>9F5?27`gJT?JGZdg};ASA&O2%qe?Wf-VbL3YGKR}d&tQ-TjHjQ9EsY&c=jP^X-j@cco|{Ul+M92R$_Lf|_qKh27$KrtrnHAw^( zW}R@NJ4!aAgC9hX4G#B|iw`#}RfJRUj5L@z4y*yz+Gh5wpW*r2JMId+Bz%$BfGCZ^0@`edcClvyT`99KxsIu$4&R~?VP!V}bkCQ* zqZEvTIocr%2xd%rb}N;GD=(eV$QCX`ST6PcHLBA6Y$*m)Yx(de_k0nA>eFx@BD?$Z zz1?~_L9)eny}u-$;j8ZkigUfuZ@!W)fvf_{c}f+ z$;3aA;bHQw=ZbA62Nz7QuqN6k@Y9%n8e{#Nk=e_8x>aQXVUlE}Y^(;OZO{l9wM@+% zeoN5^t+`;CNq^bWS-5Yux?rj@i&bmGXI^u@zqL%EZ^X6%P)_@`Pw}w)A*Uw`7~U<_ zi=PQW9fGN(S{oI|)kXli^}zJ8r6CgBLOy-)$H@Y-xpCWEkRxEi6M;BJY)7a{@t=TL zn-K93Nw>h1MqTy-PbC{Hxg(KO2FWuO!bnXKpeKZbvSB0T4gOZv^nJ7^L>d-j-2F@u zrP|Cs*^sZr{a2dIXH|UKQT|Or>VB}fUz`xBy_{HhiraVWku{Uoe%ac)bnt-bDV2h+ z3O38%64$)y9rW6>o+rJ8n=UsO$DXsLSV-WDqC?GRc#dDKL|eO<4mlQ4mJO2)!-5Wn zy3WU;aaZ7~-;{s3?$y0DEC+5qE}VT`8mOym_Bvud!up=xw_H5$8TTv<=&nA`fg@`)CS(+*RvNCSda;t`@2S1J zZl3}9jT%SdA^8MHFL&JIa?-`cLN7EkQZ)lms;u1E^jiNOe{Pv0GqBL+Q4Cf~Z#5w; z`ux7yQ9z;K-B<4IC>0Z%V;rg9LQpC#Q;im<6#gdiKptET^yr64qLwFK|JLCc>TbY_ z944LkFNulrlS#wD(+Ta&Z!jD!DiP4Co?|GRtc8bx~m+xwT6S_B- zwj*q5*D1O3X^qxdA(Ra#+b3l|r^Zl}Qc_sAKaC>~b*ln1u{kAI$X3fUA9CiisAESM z@T?&kZ2`~Td@L6}sx{7A{3f8j}|hGyxz zu8RRI?$4o#IaC3d`sGt(U*dq#$^m)sZO$d^-_QsCYqXKV3NP-3Grsb7Aidb6yRt&F_MDFk3^cpv~P!JGtwjGi_Z5Se{vNC zJ-Kq|p+}j5giR}`It}TCio+(iZHd~B@O-&3F~=c+MlkGdz`;d@FhO$LktA@IR-IdG z_qXV!;A~3+K1I!`4_I?u*e%~%-NUPz+Q}^^HcGdP45HgFADmCR(|_*?K~rf=7L~ z$i64pbK2J{igQZ>l5mAF-Kz_?cAyq@tgpBK_$h+9&R(uSW-E8$?I7SJSr)V|)lMRm z2kor*^h}FbG9&-Viu+>YTifNhptijL9!!0@(~P(m1sqyK^8eRS(yJ4Qdydt$6;9L1 zeEL#B6!|Gya8?H#K2h}%D#G<}{^n))AAimL2!UNIOmfFIZ~ANgnnBi`jg#cN55}|? zxhD2spJ+_*rJLjD6m<{cqU<+X$?m-3!h20HT}5VNgkS8}7s(x$JecX&UF`hBiLqqDtXbNfcwh%KKYI_hVtQp<2a{CoC2+!&CGj)*h;}Ew zZ><^bW@H`{cjb79d{6Wugg5(+%M4`;u%ZujS*u~ePx2sQRG-(qf~ zlNB7njG+S!asLrw?%&x-KLw?WnEo=g!HGAio^S_Ss{)wf^;%fCXIC)w;L`mINo}5t z&*{-R3@Fw1w4Md5MHZ5kY0x7ZqWAH*NWPglpBLi7jiBg_CJbO{gu51`0!I!Z4>4H| z3?Dc!DN_Mo%k+C0A-z|5GJl2lspAFGqB#mN@xhzR^tl+O^P%Ov;wkjOeNY=9i7OGx ze55{R4YVCV0Qp6P9|A$!yGe3J`)GChi)e_a)+HSyg!8X!et4)hpO7~$$a7x# za$U(AsXvaY`ZZYo@BIBPCTg0q*kU)!t>2QG;>W%(qv=6K8rIbHkt-aCvlh6TrEs+{ zzv})?A7HX?czL%gF`fGFhxiY^#V>jdrwyr^mFUrc2d|wR%{~xT8}rt6#7TyNuRmk# zs5n%UuU>IIAdc#r99K`v+(rlY%=DMXahc*gxIdsqhti79!bWmk?aY36Ds->Xu7@kg zlyJ=kQ22)DRubJU%Z{L@yMj;!xJlq#RnA|uv=U7X_Z`tjo#1l@=yJsH@?f8L(KN=$As-y_PQzM@)#A@8 zxnXLG*H)rIt}s^-ZTsdggxjeAch7#OY2Ad^)iEHa?+jeb4T_G!1uJ-IgJ({ebBZqu zN@e^Hh8VGd9>w#ejckAD(`ri^E1s3UXn)ZZNPI-sV@HM(R>YBj>+zg8T878~OuyGz zH#Gy-43*!t#zf7Sr4j`;2Q~a1gQ|RJa?1EuB;D6K0Sv{-3%lPh&9@l5pdnM`dJW^!ne9 z+C68#q6X~lO%4yoEHxiqGNFXWA)J(r+BOVV+zp8=)%h62P;_9QOKGTsoc|tf*~e22 z>_3_P>NHRDRR8oJdNWYO@mx;H*rpY0OY;-yQOtP#X!?yKUsxtWcSU2_-2Tl32SvXS zECbIl9p$FcgCyN(_=Vb&{QMK7n@@=V=H}}A=lL#G5IfGJ?AsXp2 zI!Abc(E=F`W|RSD>fC)z`HkyYwHwdO zKC5Iyis+K7UE~159rM959PpsktAJKyvXyT~aO8VX!PSyWge7b<{L!W1l8Aid-}v}A zO#J*W9)PAk$Voe}z-B&}1pSyLTcu8dNh~+t-t_xfNIX-f^OMv`3V#2|1QLhq@)yf( z2jyEWuv6dBk?BG1#rdqwhL(uqJlQz2$jj7Nh&tUZ_mXhCkVSICXppVu?fHm1`6D?Xh&(MH(I*LYpHgQvUMYqwK0Ybg$ zrjXOJ(u*w4{tCYLDpP`YJ4Oi$n8jbAjM}R*3kt7WGah31gPn_uiP8sZM8@0~V5qOV z`vWZk8U*hJSH9u#M|mRjM3hdt=zYjnSRN;IC17y;HacxOg9XPfEH8 zM@9lka!?fRPn#Kc9q|+-4yyMeZUeL5v8sx&Vu-FVaPEm9R0)Uni}iqbq%;GgZPeBp z4xDDkjz$?~u_6jfMrToRy`@wxF(2leZzTVYb>gF)a`v1w?a6$gEhN746fG`GH~U3) z+DZ59gO|1LWp6tZB`i?f0OovH0=w))_^#3CK#~wgcuM zR3>8;M%n5mu(T!|wh=B&ZRlig_~Kk^ChH&8nsgCg2u4x&yTnE-D6fGmcI|4F;N|z) zxnu{JCskpA5?16~?(x^$t7n0OUmY?ltk?8Rg^|C`o`F3KhYUN)zbbwK>}3|}B)-R; z5aQZLET@okIfJ9B+3H?VZV@p9x|-y~r@HH!UQIbuZdb+HESL^(($%r_wA!Yf{@M;e>ub4)@baryl#pre)WUm)qZkOvw@QPLQodEgZl1Fd#Y(Ct%G! zE-r4G4xv3cIjKD#fQs90U{-l!j3f=E4h%cMS`#<5)J@@P8)ZRGScSWd4dE_IUqW+=lT?>W)Ko_5mQ|Xin8^R zKHcDb3nJ9B(O_ZHRq21dPK$1Dv&Hf;giCJ^MO({%y~c?IJ)n{|WJv9y0l7bEGM?_ppn2GoTVJR!;du z%M$51lnFgj6la~_=Z|!#NW!VM(TArU`_KBh5VH_vY{@S~U+$Yq!iz5Fue+y3B0^J! z%+XQ6ho>H>1rM&EV7BdF_^2Vxf0m3{T{kHio~7jB@}xhHp&Y^|Icc@0jOr_+`1`Ei z{54U>{t#2`xAdPz%8eBn$e=Chp-m5z;3zP0M!yMyHq1l!$Qy1HoM z^fq%XjsKel44sGpEEl@CioXf`VQPDsni_|475*SN#kF(o@;_@|@@~L$=b)r?H*;@c zN!XQK`_^!hR?oZ5qWOp5Mq`*bY1nP>ZD1pDR%ObN;D14jc<~PUftVz-Sg5ndwR>eOWcLVfG& z<^Xa%ZEZ`@WGxf_2O>e;z8cvG*~O%w1DQ{#iAmPMLro+Vo4`U1)bH3#sO>-}VqT-P zMzRTkg;9n%jvBb9_~sQ1nA;5+x{2Z%NDJqzCi|B%8a>bG{jNu$+>79?$VRBp?2rMf z#2X%q;t~QV^p7r_EfxZn2yN<9OPKNRz2t~HlCg$hQ0C2(x zf3)kY0q~0m@LAIXA1_ObFYGTb(7256>EAHM-lRz19O9ZS@0oe+{B9@(qA%_T)9%tC|OX0<}#;*#o@&wk{V+(!kROC7=#FZd`iQ3F|$YFqKsE7;xXO1m4Wc^R6o>ToAoMRjrAME7VT&Ap^r8zqzt7PadH z`pdC|?^;bv*|TILl>JU1_|KaG99IAM5a0;p{X9wF?txz|PUCU<6TQ;p)gIt&v{eb^ z={`nM;SJJI#A7d>mShr) zAQWC{j}w-&B9WYtlblUVvp1xmxCAa2{JmDBlidd3`Hr7Je>s*!@Q>u!0R`=E1J-+Rv4k8|(O>|?d|dMs1l`@o4pF`4;I6bj}ybk?d+ zkjlHNxmm5De)O846n43rM#wLS_faStZ7?BoSK~y>aD3}x&dDYvQP{)&HW$iT2DtlB z7S_~w2Y}yA1N<+k^PqG9-^&7jTf+z`Cu{mJB@X=Yy?aCuzPNYq zQvb0($!diEvid}xW`es5#ll47_&$2Po>dOy#1of-(TyLa@f%yb(wmA__2#t=NXtrZ z=8e`4?f^D8*2IQ*!vk>JC0}=6Qr4e_Z+zf!h`F4WYTE+*?xR=FW?e-4AJX{=Ju!E% zz!8Z&Z`cliZ?CKe{=cZJ2aXTCuSPiF4;co*8I=n3T8^-k!MkIE>cY(1IOvL5(T($@ zQv76bYD-<*cPs+f2p0O{yw?SL zTQ@C^BehbH#v!f;*fv#4b7Uz|n^1oik^bTj4AMGrc)AvWOGA|g}x@p^4-FsXaZK6ILNu?&aP~dkhWpx^W7cUEI5Z4H@n;RThOI|p(;$67c z4`KLdz-EFM9M_3Gr4o)4+N!~5Fw7P(hu?j@Dn|Al=20AFbu1EjEDpR9{LDgnVy0%S z@PY5w6Z7d*fM--lYU?PJ3*J*rg-}Wvif#hJOA7@o-isc>V{_L9Lt)3?jhe!5(s)c` z@vuQIP36+o93;1LR&HujEmo6g!Q%sd7!PE@1^(S3=IuPhOzo?NZ?8spZik+j=@ch~w2nNcs~JX^`jcUV0Gw)sQ#Hj4{v|!&sfn4Gx2g}^L?%6vBi`589wb?MQ=XQR zIZ&$*mNSFd3K;hmJ+$Dl3um}pR0Nj~T#7m;mE{^)L>!9GwTec@eJcmH+j6Bi@O9Ke zxh@w}k$8e%(gF!P9yz<-_A5~;G&oV)=Lps6+8Q3P@o;7>=0#9sk3nB#I*E5VTg$`a0 zT-wP8jy}BkrPBOPZ54$A!IgL{iRusFck>KG&0pRIENgW2@;RNsF!2el* zKYy6#sdLoN0Dk&7U5!C>ZDvd$O3SyT>Ivnn-02O?`HWcNW3kN$Me!&2u3>~2-u>xi zFQ{(lHihNgo84SE!+E1xDA?fcLP>z9zOr{FyNy&)7%nHm2&skL5~D!O8<1!rMl%^p zj=YFEF)dlju*=RXukrx#BJa)l?{{wRzvZz=HR-$&Qv$da)>AtVg&U|b7SO&bzLCbMmyl<2wdlk-h6h85x$Ya z2;YuGo={H~xFdW|K5(Easc*4&p`UAp?g8MpTYGFa?WOB#dv%@1t4$d`yWU=zZl86I z-CUu~L8ymRdM+`&oa~2VLQ2S3DF zRaVx2wFl$7{HFb-=U@7m<}aPw0m!SXN^k1Gb_}@ZP;7eS3^Al{GF0Yj}1Lrns_1RM`ve?^j;Z6my4Cgm&?Ub*C*s^)0u}#Cmuk^Hfu0kqcO;a z={C|sYdqvaDL=UwAw>4hJ8+$If_v>ysrxv9c{VJLa5t6ybMZSeF++XbVRmYv;6k}~ zfJ5Ht4!})kp{bnY^n}Sv(XpU^AAe7Or z3neZwL;V&btZSj{5#Z0Yz>nsG$;rt~+tcak;QZ9KE(i0|v*i?mFXqR~h4Fe}EI%Gx zESEB|PY2VJg`GIjiK(qFPNwt0jEmBhThu7uLPBhj8Fc`@5v>7vId&TW@3*B|sV7TU zrBO^N87LNOor9rA|Qkc&He{CbduFGSr4+t(mB0rD9f}!Edhz z3$Lr|O7^|_i5_ffh2$QWO(1+*3xzNgI`V`C9_lTXUcv(RVpjJJaFBiM06!WGj@+y5 zFrUh}W}x>%<}&S_zTk`y<~0cqI9JR06#ij?t}uaHXDlpyt=iA&24b-Jb-gy z$(v&t;G6AsdmDa~zQYJjib65`3f8%sT8t_^O+*xPz~Evtg>l!^InQVK!|fQNd&#oT8i4*)+mr}fzc;Lgm?u{@p4 zli`dmlWzrKayzDvVS7F|VW;~4!ep?}W65zAYi+s1Y&|2-12}h>tAzdCW7mp8v2c}$ zLg_UM<&zReC@=L;7B+=qv&pTgTJ$7F6Cdt(n=oEGY08@)?lK#LH!*}_e`>*#m8|jB zw6+azs>Uzq_Sh4q41EV#PfU4G#BhAzshCyxz(aRcWq@}q@OuV0njB$=Gg<=Tb7>=ljaV@}vUz`Czt`CWDjmHK+P>?lAF;JRjiCHk#q~u8wK4jl0Y~yV7%u zX#mgO>;&x9elAyEe#y_2fRgmWY7$n&W5 z#Eg32p`Q5N5SwY9uM^ZaC|6uPzWQ$ye^Qo2@xe2;FKjK*4v&sN}!5;#yMRF z@y>%}dyn~=P+QsG;1?SlTql;ZQC)R1D{A8lA-vKstuMVUF$n|@A9$#zFmMNW*9ZQ~ z-T`h`k4HNPCN;nv>gWOwcMb5FTqj-63txNQ9p;s5+X47(JFFsCiUQz0HgxcMvY6R& zeaQ4CP2NP$27KMQ)q{r-@}XRzR)uSAgKIqD*y8%axN*xn4KmNYwQ;U;;?rz^more` z3!;!iKT>4MBU_UNE`Fyt!mz+YJrTUFCcF12losGv*b^rsVcQkJk680XK+jdC=LI1T zO2lPeYkTW=m^bM9>h|Ww0bcRDd(0C!4?_J#q0rRW5bT!s&`fb!6ccZvHGAjo#Z6TZ zT;I~jp9BwLkc-592q5BzP@9^=4Y z{rS~F=zlNptzQRtCip|JzzmKX2uCn5g3eulU_P_AIl18{y!00r>Ydz;U6-0+*-cP*%2?-4+ARN6j$}+L~c} z9}3#7H(Uboz#P9fRysX0(+4-oxXBUU9t`fu^a!(@H=~y2%!|CF?1(TzeBc1ww0{fy z-KXykLjQZcKghML9;OQ=M{Y}?d3rv(Fm?lQUz?@*iE;UKGB}=3K>PG~c4@RuC(h^7 z9Ncm~n+(oNBG1uI5Xy}(eegE`XQ|byIwsf2=Js|IVTAoz;O;?DJ#b~>3{y5{>PtT? zlop`Ls88q?t_64Rn1;L)&lqTHZ9Hys@~zw~ye`-V*f#ZtBO1n60pXguM@8e}7`Hr3 z^~B6Ad8($y_tuf;x2FN{7f(C=9Hc$EP_Tzx$I~+g=TrA#9~r0zbD7+eX+iJm2CPnGc&JoW&7INhsD5dwW{}?tqtA= z@vy(|Xn}v~K5#5>BRDP9K5l~miQ$$pE{};UaiRuax2=4LHzmT;aq%5qH=teLB#TD} zX{ol$a0o6j$V(!#S1jz9-YBn*G#_DumsI-f1AqG-i&+8e0Qie1`M^639h~4VYDb=| zRrKX~SgI^=2Y8~lczkFt;!5N`kr_@*N<*6-FGNV$l&sQ>he#D^ti3T?n$VzLKJd?A zv-eSRuZp)fcGYW-J^X|@Lm}KOa1TPc=P*L_5zEH+nh0>D>Us;W>xt$fIBH=D;KgRx{Z3pg6dxJ% zo4lOtJvVfK;7^|Z@N=slY!dqa>#M9EOi$3$q>GE+e}DSY0DRM{!VlhC6pBeM6sgVO z88&)BWjz^?n(4h9G%Atd4R@AEpf_5l;)FU&42u53Ya~(X2S$6=4gr_|WQD z=e0{6S(+PBa9a6Tm|c zLah({0}&j66Gx~UOkCUa;!#8jzV2dfd!)EMycs6w)=S%CjbwsrKqV`Xuk{8C+erS_x#w;UEYnhrN>*tnv6%}@fSsmLyTF*K!yQ=HwmKuHyQN_g2c7|L1a}{JSm2?% z#}Ph&M4nLSugb~3W%^JOo&hy=!0kt`0&9~@aB-da5W*AUojW<`8*qJCz}?^gH|3UU z6cfx8_n`31Z-%z%eH?UH-|AdpYV0l)3kJu9lFG^ALJ5F}da=OY+kZ!%P{=KCcY%MD z4e-}7uqDBkq)V#H=63YZnM|K_Sab&MHfBpN!omp!PY*;fD-~ptw-xP zOxiB=s+TeZ-mf>=?M+`bPS}(?&D2tj7{bhK(2Oc?H=c4%5eKg0E&Da}X2-b2>F1CE zUVPwTfrt7)csCPM{C+7iF+&ebq|!^dD?J3yK9mxM0-p&Iq!%U@x!0-EvnfkWHoDoA z2t47*4untic8Wt^K%BiN6a?1}554H|z)4r;I6>f#sYd8cF~fxt>dyoIo_?x&vh(nP zhxVY#2hJttCz3Tawx3crHn&r2$mnUIZBx2Az;$nzW?|nPq2Ysy$C}Me{=5l(do2`i zizZ0J@^%esyCDv#5d~#E8FR!?Sv*$G%09IV<% zCY#-d4SngkqRi=+gojt{cC^jh7l3*3gXfMs`FjF5qMfllJR?(UUhRY(IF0hiXY7wy z9gPHW_ko8CCDeBq;of^0>V&OpiW3}9cTz(bVHN@4 z@~UVn0oUXe2%XGzTp-J35zaF<<*_$8mieuQJZ(pZl8PBoD51OKyVDa>3JW}R&jR4) zL;18y-oDxNeZtn7?)%2*B4+Au>JO! z44vd2X-Kq6WrMf5n*^blXz1V;xD~Tf7~$LDLJ8d&;SZ%FPbee{{3{P5%mBxQG9H^5 zj-Q*9dCCkbDutKw>G7oSX>_{5ATPP|_*GgDxWFyzo1!QUcb09&4venC1>Fi~UwayW zZG<7tYs7S917kP-xawVS$J4TkZqz#(}5ufs`0*1Z_=GHKD1te~ ztc2;sUpRBqO;-ZKwLNEi_*CM4#dfmvEHSF((=Way}9k zw>C#!E+x58RL@ElxK|^LaDU9t6NW44gRd#BZ3~sKE|lgG0Jpl5?rO zww>CLq@L1q$7!GZCajIyC=@SC*mxAUccM3GO%Kd#^qRA~CcY4^nZ$Wg>E%ReFEO#e z;}SE}7sBDU6mj68{m5M?p2wpwa48i7rxu~%S@bqiH;0MEusj>y#otZJWz)u;V1cL8 z!v0u$f;ogA%OV%R{i0Gs%|J5`J|;$Nbz5Qbgxw>uct&!dcuO@?B=Us%z_xx1l_@vR>Z!qtEqc{XN3p|Qhg?cXs9$o1}`&T!WzU8;IR7+tfBwq^U zt##dGclT}>N+vkcWDLxi;RH`LQ6M`V*GS{Z(Dqc!j_{w@qswg3oXwHDSaLPOsul_^ zls5z5p*sN_g4eaIqyTv65BAPwRcasz!!1Ho7)%GeD-Mb%9?*rCg$v!BckxBs_}JFp zU-gu2bfdwHQonPO>h8GxD5~r7x3%sE-T;2=mzZ8^IT1{uo~LJh!}6lH{+THY3EqKj z%*uf`l4Yf4R?m8jM39bJOBq87$}&LP7mI#Th^d{ncVjkRycc3-H^S6*T6H6QmjfMA zduar?2(BQw_+76X!8?RioYPA&NhRcIyX__MEkEjXnqp$TEaqWW?}g%d;9ZzLmL4mD z+(GjWY(86I-3UG_4SeqXz$HT9@3W96wN)v7rV*yjKx=@T+yQ@btcB4H1hESx^Um=C z!a;QS+59=X&1&v8hrB#|BJx*eVn)wcaNbLL_y?~0K`54iaT7+1fLk766!K&YJhc(Q zhf2&WrBCgq5#Vnhy9eGXG0g!lCike@yG=1RTg09U2QfiSOK(@GuSIX2g~wjC;T*3z zuRhQrGQb&d?BtdO(pe7#$J8g-RPpEq!<7cE5O@!QXC8QJt4qwx15cfeB8`wN6nDTW zrMGKL1;D*8xMvO)%6DYx)Jx}~w?TQj&WFIe_Q5=r;!Azk9B^(kOTWBCz&e-=jj@rI zu?4D!qdrsZKpvrqvGm@I@OFk!QX5wCyct3$3Gme4RW!5010O3fpJ)zjc2a1P4T^$C3;hq3A!n;*Pc7zwk)S{YPj8IQ;XAfM#1)8Dv!q6d$U+50!|*v z&`tK@eTGm{8#>@3cn)+(?I!?^2OgK0>PC2sQhI~99#bhqNN9ReD9M>Tg%F50NKYI| zwn0mAe%0skR6X@=p$kednn1WcVuuGit9kU=rXTnWz;!w=hHyeCr$U~Wo<{g8(+E?W z1i>SOl7&2}okR#FJaBixJ%n=eNP%1Mmw9d7R*T0Q}nP1o*!% zA}ILZafz8(D5-rk0{q@vA}CL5%Omsw#i;nRMD?YkT1y{#tWB8&zU@__7Yciz+sy)* z5ixMqw$F|!rt|Q(D&Jv?CRU4#S$$;>_6QH$d!a-KC6)R;4I|aE8)0fk5d$ByP#`!1 zdGNP;MSal{S3BBS$?z|E_}_H1VivA4DcsZ;wW?jZU3m;VCeX_ypAfrerMPp=17n7xFm46X2;o;CJ>yNu7)Ez+)z+cO&eVn6(vELglONIx{9DrQepFj`~PCfkegJ0uTEwo5y!n{eB&Gl`f~-0&jX{;CIE zZuB`0oyAgcy8jIb9}-Zaoz)WUtjwI3c|I&Hgd&7LKZ9N^O7O2 zoUh{1_U9oDTo|8yEd9{TYM&VR!{>KLz5i7@;l~FLK7Cp1!H0C)&q&daFlJ(A2qm?F zwh5ucRO4e&B6#ua!MLW>E283dR(mh2(pk;`hpL0dcWp9?9-=cF(?TfFJLY1t>cWov z@3kE4OB~YrYSu{O*&T4w2=@W_a(AugKXt+{KYf1jGkyL1AT!9%MnAMd$q>pn_Ri(S zktzz~DZy4jY^6j|2Yf&)f^IZo385(RwqhqCB%OY^cH_9QH-n?hLQwx~&v$>fax5#O z?JJ3zFWq&YRk_T-Z_b=L=ia0Cq~>N;&Tj!6Gnq_Gk^E`SHV)>u=jTv}*3}*0Zl%osfs2!>aa6gepn^`??1~`uGlM3C0b(4k3K*YEERwP3` zd!WQHLk#a0nAZdhaiUNnp8+=XjU8U0jKSc{0pZl4HZ8WZk|SI`@K?|#k;k-C3mksm zmEfOXe{a?IZzo3`hBo}ZcRZ2jsJ$x!ydH#N07t}qw@x;DfSdiTxI)h3@#J~NxngM> zv7c9soe9|mMOPds;N4s(I85NTzrW-+wCM)TYSK&7FZj=?bLBjqQx62+!)}E5Pwq&H)$qYgtGfV&0GcCtr+AGKpO3UJnWFY14s<-VPj+1uPpZl-$l2>|!4PPmHM zvBVjUR($hf~!ofyaacMZy9X!S|BLqpz$qpOod~P(i1aw_J8ih+}Kz2iawVf3Y z2nW*R2X9%Gbo!Q5ndyqxLEX zxNl}9fQLBn=U;zb9_|o^^4y6c3Bh!W&~P;f69nCC6K=P}Lo(sEY;Da69u!H1Iz;&w zJWSApGrNBL4tNLT_)lur+Bs{nIAuHVCrPCz4xHTxi38sez}*A>@pozZUGedI@684B zfqTKX0$jp%^3~%3o?Ef5x^@KcPWn3l|6iyUg;Hf={>P$Fmi^^{JmjdmyHF}0IH{PV z@ksc&K{ltV8KKpQG^PeAnbmP?cu%a3)XV8=p~uFEEflB4XiBQo=wXadaro>~N40rX zy*EteP)tr#<3xvDA8L#T1%40Si*<9QlvN1Ft z4U_DNqy9NHGcg_DufHV&+*~Lyyk%?-i1tVmc}A@1DfQOLYfT$x5-@Mc=4LNhFt z{Zm>SR>N_wp&9p=CH$Kyr0<{xwB30^@j}R?kE!(gi9&fd`cI7Cg`s=2_)f-m={ESi z-}He$FpwL~A8Z5o`Ptbrchj3fJRhC~o%j5~Y1tX2Y1eA2MQSPlrS(~FXE74>(*dkb zM`5f8V=LA3ZYtd*ZM#|0cp1liLg4`C*_x31BD&phlfElFXV6aC=|Vh@-yX(%i5kV~ zXT7WRP+oF2&cys+FDLTA*`fkzlHU#aNz(s20bVATM}Y4~O&7|Cf#6jpy~Ha^+}=bH z%KnDpf;)_M*SCD*bkm2go8vFB(pBJAVK>6~GE(wv%z$H!=mX7TuUbAs!!@9D+i9Gq z&8%>tQ~=-Qi9GHCFY$XD#zD8);1hR(|ISNHL--bemuI5*7JwVS(dUT+{ARS6WOG5V zn{~!rP&i6&=Hq#Hk!}I_ARUeKtecKS-gJ`Z^TEVyU@Y)(Z)xL;Fy2|D#eAG~y8s-O z<-9W<81)_Ci(*k^Hf3($?~KcIq{m=&->3xOle9Z$iPoEIW#OM5<{pHi|HOXwpNaX$ z0X}5jUjg{lIuvMqHB(WRM}Y4~%_x-k4wLg7aAcpvIFwBeW((UoMw(d3(Q3jpBjoE? zE2hx%!vukw)E>D|Fsvhz-HDlh zbUSdgtwy&AaE;Np(WOj~W=&!_^2vjAK%?mcU!)xec)FmWMF8#UIURi+0PifinRb>z(&%xU}8|@b%f*wR(q`;0Uxjr3dMXi!^BfTw~dWwH?4) z19%y&VZ*F0Ic79Bxv-iY;4A+ITq4Hl;6o|&SOD%z%;L;+l-ZruIGiArd-o3IRQhcI zzn%^Iv2Ph#sxGZus&bk1WAnP^7FVZAngX~@8V-HVu)1NIocEdOUiivcZ@ z+`rw&!lPL1dI;>-PaO3Z0p8qBmN@X1yG=xJ&d98JPO5}?HC3q&Pz#e)Wr!`UkSaFP z^0P1z4cg&^9XHES05xq!=mYU%Owj9ld~{)9R~D-rLIT)6&H&8z)ge^KCM)GLHjxVwy?NWf!@KnLJlT= zZxLIN)(p(-(1+WbM0(6`b#OX;OI{NF!fu(|E+Edw^Db5B<0VHuz;*I@H2qn5QFw{z zi9EaAPWB^XG`9Cmu)9qYVp2Z7gAd&F@WP_Pzik4%pIsA#QU^Hh74=T*0Iv{RY2rW~ zHvq1&N@I;#^CtTxz*8TW|Dzd3*cmKru&Z$drI}V~z}InMmA=IIZ;W;1R<@JT;iUN- z(4odPfK&9Q>vc|da+&qY=}dl;{Cqge#pzjJ&8yjPUF7}xQ}QGk;`mTk-YaLbB2xJ> zo34k20Kc4FCdF*Ft^s^jCfRH@GeV<6-*=w(0C;Jw*?CT5X=~G?@@2KyX*N?e`7cCY z?p}T>Jz?OO;4cZ_PrqKB+|VKn#R@;OfqrYZRKnyK4$elg`p~N@{u5+%H9}qwao}Qj z{X{jt@(aqV$)1pD7eIbmRgrq+1J|eK1K;UO%<3Qq!_vrn->b5mi{HZd-2i@jVcHsn zlHmn!09?HQ{CW$(R~^*;J%EpWqDj%~69>4wCe?To;Lv(7ZbRTEzzcu2+4^lvcUDy0R+f|vh?OP5-4)B@`nkLY1z!o{|$(1%ai`*KMdzK@2Gzy4*aEh7z&PXOUDG$z?=P1BBNW?Ou%h* zx51wW_r#22ia%W$!-Vynwpk11}YrD76B=z-unccJ)JD2l5d zO;)SH4Q>?l;^P1=DU9$|UX<+s?=3O1>i{pUxH7+|6-5K!W8$vSWb?WZfRDVzn2Uk2 zX7NzREHtNdfUmf}y=Vg5gTHCPXjbV6j42begniK|USE+$4S(ajV(sOV_=J*nGc6mpDX9-Wo2Yy9M%UR0UkIPNMe-!fB z6G#2syAcvcNEAwoFcfV@h|e1egK&~BPc#K+Z;sk|o(bWCzeFE~agD30snKTwxIO+v zKQyqo9pjSEixZgv#!q8mEcY{wJrQ%40KX?EI9h#|Mt!FULiE(12RIRf<*xzW>svXz zKEnlG132N^osbDhf=ovA--uQWx*kE^NfIaK<4_ZSv8=JjSBw+6-3bK%kMW0X8yz%! z;F3j$lW5tFOR`C9XmF1#aYy)`v zhDFexhddot*;rzhZeq+w$>AvC+xdMkao|%kt&ymxE?g#4??uBR3GWfnve&am&8)n_ zPf`cC^@vWq^6F+wc z%Q_U%aU$UyF>!VpsX8sPFym|qyBmPVQ|Sd{{R7?zUJB~Zw%&far)=Z6j%Jj8&zU5>WF{l{ocgM(syCa4 zFtE*L49AWJQ7OY|noeU^yFPUKH6MQV+55xi=j#tYI>%*y@!9(yepG+Bj$tHU*u?cm z#@o+7eBWr@&^_2KZ^iNVzp!zsvFi`sZS8Iphd+MnqYqp6x$D045buYFAnE?YPt#N8 zkH}yf$8N`iQo9f3=HkF=3g_Dv=0wzW~i(FAPBC#G-DgRQp@GiuG zyY-#o2)!P-drqJ{EtFZ{OcBjjblR3`Srp3aum1Z0e@-8HLC@B?o)y67DgeJNowroG z#abv$jZ0aHj{uyg!VzZE2BE}&q`Qv+%TwLf!wldj{w$36!2Ld-$c&^7UXi}78=?tm z;hTxp{A7K7?@!(wizDT4#)0R%(uZ>3q|$o?xE3r!GChR03mcV^FPt;Lr<*E-fL-V& zuP4kF%`3KRfY5GRM_b&Befx||*xZO2D}I&7W3Q)uM;^im|J_VX)-f;W(b?2zzP}9c zuO3g+3cwfDceoz0IvJnaSeh&y*_TJ8>ZCFdfnMX#?(3z2i^=L1ASdW3a_~%k6hr!@0SeP zv4m{Qrza)TtDoo!r;4vvrrO`ui0p>!&^?v2vX_kVU~6+AEhm%uZNPq#H$2QxD|8Ws z!q^JLYj73!#Jmp+9DpydC#Ll5+kb%H==Js@zkhtjH-9JqUseM6jil1=TYVVhZm_nu zM|;BKD1(@;v{qPm%{JcE)I@){w#_=8oA}1ti2fnm-WhgJeY>XsAwlOpkpy`bBf+{& zv4JC;?+?0tpTJLP0?e(U7onj@DqC~)P~2vZ-nw@h!lR0|J^S>US#aQVOi_~ZYk z3k5IA@AI3P?|k>;1%9`$`?Lb^1w|rH*_Hn4D#3);v%p^tsq{+Z5v9qztl)E8qfEuX zBa?lBlS@o*HsJAcR-rp()W<0mTh1p-8(4dJLT5E?6#Javz7MjmVptqEOh;LG`SvR8ZEW3lQ#wWew!_i_&;j?ka{v@RYuj66yKHiBgegneS`~ zr&C-ZkUon(PO#rM?ZJVPwxyRFB>{YyJu!2JKl^~`*+TFCx-Pj;hyyPcc+tP3%Nsh# z3m$ynkJu{!oOF5-TD}p_)uEh~01eq8w?>$Cr!H^u2SDVObi#$=yA^w=HwSMqBQ;Yj z%8JnM75&Uxg>LOh55P&}SsdU+{|{k-&vWTJQ?bB{{%v_J6bgb1-=P?atzibknwKwQ z(gxvd^`K#5Q-TTZ<*axKOr`{`2KTp z4ioW%-)U||GN!f8j_^CmSw$BL+$McK2;T`{CO|wVIdgKi3d1W|sJo>Gs0+zxVXXStRV(JObs z-=a1?6zkCZ3PKCVqIO=^$`0!Ets4^KIhu0I9Hspn9yYDAJu#QIz@L4+IKcl0I{Q$T zPvj{o@-5X8z@IwT12?}nUTiOCC5%UFTn+W!k8ea!)=RVR;uY1b)T~5xx-`bo+$zDJ zO|GS2Z3l8J#c!ID#(WogHTcbA4C#vPl_J4cUJpC~{A~cfkOltqCr{T!|8;pe@a0qK zi;9Q?f5o>{OL>?kTf`)l{{E_>oR!yw#Z9Ui?_^N=A5|O8>SpF$=(puHr&*fJ5*Sg;Mk{Yo5qM6bgwvFBo4b)E|j7x=sH`f$p;R)D^3D*6`*~;ye5DI%B8fFb|15 zIm2a-XIt)h?uGG@mQ!E8% zuDOyZBFl=Ujv}{YeK0({D&kErz~?X~;>4ATpSb&E79YM!TdLisdf>i@3BXGfO3~ky z!#nXCf_Ej6r|4f+4se{{X-hQ)p_r38dBPtd+FL<`IJASX!8O1aCawz9Pf$~9jn_CdDL%bdBEpB@B;9ntCd2@eBdRFuxJV8 zQ7F&*B4!}?OO;S71@d}geR6k)R|*XW)q(R^=+HY%@^V&oAcl~A>tQqLmssI&nim&9 z8jvFfxN*gG7L=k|D8_K2P^g7cEbyXh>0%uCeZg1DVNIvabftGH8$J993)}%t zHNs$k`;sNz@Ob6rP>k4ga*)rZ3VdcQlL4LF6Mm-wwp2?=^t#X~t2pQ#LUoSsyquL& z{~;DL2v3#i7nKqt@akHHnC&>^1Xh4 z<+}1m@;vW(pL5Q0&VBCtnyj+OA#81D)#b)YdwHxSyCKDsDF4;)4lt|azA9Stwu{t{ zjZ)KJzTLk|xUN{V9g>f2MA3&XV8FZWd4J$e@MJ%>Z}=r?B>|@$O$gujP7TV8IQl2m zo=BGpBa9~_n@VfX0H-$|F;C3mN=m4>{yMpHcWqd5h5wd)q=#Nvf3kOPtZjC8Hkf=2 zhUv`)4nCR+JF|~$APf_q1P{4d7+QWO^fKG3fW!^4fX2utGr(2&T`1=oq!}j0u65&K z-byNsg~>UkHXg@}@Wll&(Fmo0tj1nD=E-*C0ANgicNIgh-BL()mxwstq7{A=L6l#f z$G%&DMQUHY?q54?iy?bUhs?W7);`!HMmoQ^mKHm@+Hc7VhE?S)U%r)CvE7JlG0kg$ z3La?G=!0$i4O2(GJ#RT_nuJYme$g!CBU)$GJ!SEuC0UvJ2KciJ3$E z+P~Fu<1Tx$$MyHu*^kkRALTM=(q-MPhfkG1ja{CmV!F7ghEu$1eTUKVYR~M})p)A& zSAGN78OI#p39ZMLJiOvAA8`|n7Be$SP2 zcJb}Fd8sljC*Mm#R-&0NN2SFJ8|ftC<-d6z7p(f|29{GkUR58HwBj8{SIAwO?Pyxf z9=&h$iMqUznL~*&<{+Cdzl30I(VzpASTKK1ynaw;tFQD1&R1H`P#p3I%g+c+Kw6G! z+%*=x86D(D*@Y;%hLS5Ga=&VAc5?-#HyQCCBHSl50_VQ!>I+wLV_f!w+sp324vsOc zSH+uvERSY-X9GrvEvGN1{n-fr0JzVUGI4 zKz9(0sCwb-z^K0-Dgt;O&&2YwsTOfDs7zNZDCQ~-zNx9>eEqTdd3X6?mL#e_0?X|# zw2t3~#^PDpHjVG5@OZ=GDmJ>7(PLb(%fV4@nTAwi#JAJEryz88X2T6mU@#M;l@nDu z!5wC%PpT=dMe2)3XTaAK||9G0x zkwla2m;$l;eoy$k3X5ATzq`1S+hxLk@sAUq?K~nu5GXTI1sY}q(6ZWYp4|wFkzE$H zt1uz682QzlW2K|?fpX78_c4CH+9r`{iN`&;D-S_#@ zlsE78+cEciB=dO(U;&t!#3efa=a;yxLp5VY|4Hbw-wc8Zw8;AMh-=#@{w$d zY26~4Rc4uUCi3_>(8a5l_uCg&iXmXjNzTvTvesaLj0v&=LS1f`cxE#v#Eya8+bA|c zXq&KPDpmY}817c+BP-QOm2uzkGXP8eaHoAZnb?bp8fn%~d=af3VNo!RB2|OVyD-PDx`!?FYZQFHr^ID_?92`JN%3|NkzcB580M>(REJ@AQ{=?#bjd!VJ^oVQC=7gnaoA@#eK7jZRuQ|Qm zGqUB!7pdYX^9|ar)NUE6*A@90bAn}uQ)17i6}zaDj5&L={{)tsQ&#`T#RrQ@1>GtSIyVN5u=LtQ|!A5+Q z-<^``(xEj6+lH8P5-wpSfu8AOOO3{HTQFKj;avjg>ZC>a{TzChj%m3;n1dTxJv11+v6sW4$EIOvrDzBEmX_ z6QT4-G^aKx1iW}~OF;D!k<5s*aMNJ2R!$^`VnVi(cD3M+5{|KW6Z2%+jicKnh1326(H-lNVsZ38K`JX&9}4#iS5N3{ zwfOuRi)S|j7G-PR8XOpGIlQMW2iZxe8sT}d`xU`9>M9lOIA~&6+okur#P_{zlx;ZB z4Ku*m@}(t6e{}aU2_jw-JAeM1Gtyi$o1L=0NjLtX`PFMUf)*r+5Wn#6Q%#}W*fI?# z^O`UBB|xqW8qRhSK~@An{xk~_w%1@B9a2bPs-us@P_``Ize@!pvEhF~IDfn(K4t$B zX=uCed7=~TFTOyJkWjn48<2BDNno@~e_AXr5qCOTM+sr0Xw}O6&deNBZ-|d>rD7LR3Nje^{F z{oWAHN`+NW=E%Hilths6Q&$sk{WWLt-yC(qM#jIJKXqbAa0^=5LnLbPKUWrUSPzam z%3UO#U&mU0iIAnjJH6`ouUXb@(pckB_Uh0dcvonKsdEhhVv!w>GI$bM0WbOB?R+LP zlKOvUt-sA_kt?ph;hf)~e(U?111;}QsnyQ z*$ew4e~^1oOXKw}`RSunZcNwZPdBNJ=ZPLWrq>+M-Tz=sZjv@%C4RrZItnF2KG!)8 z*~!I$I1v{a&jyv$2H<=j9I>d3bta&6kL~(GtdZ=4DPvKl;b$5IZ#MQ4A4vyw_{^%r z1~JN6k(7JFSsE{M`WNKBt=Zn`Sd{m-;6noUe@pf-9^Q{xRcyGcM%wb4bz1z_gOPa( z;QB{IQ3hkX;EtN-5&cuy1(u1^$guWH-G{r!N^Ihk^5;+@yRub`2s|0dl{ zPua|{CGqi`K+Vr`wMDz1GkA0CEh>E+mqdg7w#nS0WL92>a9cD0r`(b!>5;Lat{cuS zq08>9zDu6l!ZHPNUP>h)qR~I;!Z!WA>@LL?m@mkmd@ZhbFYGXvZYg-Z`j^qHtiI*4 zwwdS=H-^QOY#U^o9W~k^>l`-rv?P0)uNfCpB;hKvUeT!@#i&Z{Vela9q`-nf?oHGV z>|5YKg*-+|fHJK|gZV$PX4$yE3@1#lrFL)*O0B9X?i&J~vi<;$@cb)O-g+~W}gCZJycQs30?7F%)`ZkPy{d`2fKrHYktLF@Fn5^9V{d13X zG)|wa`A)nSCKH+>Oe4%L+KU%Q88s6sl8Uso&CSoMpNK|buEPHXfE_aW$uk3y%CiVR zmS~$6%tfPUPCAchjZsLW6YYfIN=$TFlyP_F3(B>9$d9q6@)AB}y zAe5(Jg1btT;0TZlmyl87o@LBdcJklGeBXnV>R55ILE>`a zcvJy(7d&qG>PooDLTmm?IpRw`$x9E87k{{fS)yFyvx@z-*da&8 zQmR1}z(Z54kmlmG1rY6%rVlQTN16YN%}`l;@0g)1&DSD2H0W;Ry^B+_5r93|FqLug zA@Ip7dUo}6e+Po`4X;*zjo#iosrp~iod_P5JH!|c^wV1C$DF6A(ieH;;`GQ|+evfT z!1Ml2g|CmHkBS#@GM3RcfKKv%UogLpvY_`cALZw&V7@dZnupw^>$pFeyiTHFB_tF@ zFooTu1i)n!o{cse$o;9=)h9>-D`9O~WKi_s2V2BuZ*?wK5+JLnvHaV!LT9I*L%AFN zlP~=9HK$L-_Es&vQD;Y+v)Ts_cFiEDbh%@)1xz9sYrwi|iu`o^VUuUkv&0Yfo9T@) zneaR+zeCDjlW0t%zH-%eTz}8HW;jK)tt%SxHv}1=%Y1i>5A$LP(ag}^gmti z_-4dtd_#hrFw&rQpMcG2`)Ik9Me8lcC{<6%jv>zE&DQTHS3 zU^llQ)mCA!Be zyw%qKL)R7QD|*xlT1&4jnfDv$_Uzj3X+&Nqb>wl#_1$&!?pfAJr1pgG{ts?F zeVoEydM^>}gj-TLWR^y=Ha>nTPi1l`^!dPW$Zd{-yP~s{|3`QO?O-!*dJqL8%7fz z{==}GBnw~*#dk-dY~)f&sbg0ym_k&8rMsoo&0uul?#}dUMK4RA3wwF33;p!1%uqFR zj;HoQ`ba1eb&cUhVOq}tZwsH)-`6Jh4!_+4;W(-!ce|stL~yMX4QSGq%_|sR6%uYc zvs`m=cGNy_oqw}i@(m7%VbUn0^fJMq7(v}v5b^x6GwisHaOBz2j)2y;Pm@S1%c+Z( zxND2BJ@Y{K&3S)5St`?ENmke9mTam{6$W`T8>)Gcuy6&(ismN?ZNZ@~`VTcIWk!e$dtAyU-Q^JOH}cPP+L+m=P45UbbLsf zDst%8L^902<4<{j{5>$;)d-vh$V|Eb#o9!|9;C#^o2?Y1Y4?;~>J)Cc@}#|g_RKM)=c4hNbBuD^Tn zT#F@o)>TM&g+1n<$ro{XoRedRgB|VXmbLX1Ra%z5YV;cZe-<*c2UMW3bTXObf_k8b zFP&eFm^D4N>(?yVBeo}#S-A}iun=UVWGn4NfTDvthXZZ~O`jAbPDPUmUo)cU-Lv#Fu-G^zHyYLwXVPsg$e7d8Upj;-QWK&t z1p{czm(h95ewN5oiCdJ9v4nkpw7ZXuCCXsfejnjARQPcw)lAb4`W{bX@<>c6m8TRKNBmR9r9St~~IYzYoDY$+)KWj6Md zCu#~dU3dxE(exyZ8`*I(!kGe{pTUIY>_*x~*1gy#zbk6SetmLy&Iv`x#OLiAUvT(& zxanCeFVj;W4*uOCOI~F5dCY&_9l6-^8f(;T!=)Co9~dH>A1$u&n_a8k-j|$Jm5!eV zXk0ENGE`segoQFW&@5sP0ju{jpO|7H9Ay*a>&svk6&38CSlZ3M=bj8*-*q?$5py6y zUf3OO+hl*QW=6heb2czIVg1>g^Q!olj!nnY%3RhJy|aCoq^G+2%W$*xS(CLBqm|F^ zGb^D)GNwqJ%oM5Of=MGrQ}Qu-nBhe2>up)}?;r4wuEOOpu-dbl;dPqMni`hIe;Ump zmkr^IoBK5#w?{`u4<-Qgh|gT#k{jH;Q3Zk3p~}@sd|1cNAsfrhomD8Kcq((O$|g_H zmBr2dd)VH?Q{O(UH6cl7F{8g-Ugl`mKMJ*kEWO z_X>vl?_SR~tsmw6u)tDSvy^+U4g5SdszHTA6+vJiN^7r$eUc zJe}^;$zWdKu75ZN`df#brBv!@1cA8Q3piIJHNqOi5mTbM4; zkF?Lhzh=L|uv#$YlSmO3q+;HMb|V6NzXM$VFg)V!%xLxI8u70@pu+y< zj_T&y3&E_v*jB*zoMVJsdi8IHp7%@PHhnub##APv?RlZ0e{@zcDmTr;gUG6)<>@!( z_R*;%W4%Z&iu}Xjl#FC^F+gH0!;*pbeL$&EOV*pAtGzSA_v)Ji-XAeh@H0G2C=MK*L zOUGwJko$7KpI(!mDmB!H812VTFTD=7T?`~$X@7P`+Spm`i-T^wc1=BmXq~=kdh(mFaFH?x$?KwLCLOlI!Rt99s z2Tt==UShYoWCxP7#m4qxNq=h}A0KjAIIwUC1S$d$FFW8kdZ#^0l*GF7NoLB`J&y7^a6D8dy1 zl7rTZpEW@Q#QjvD{dD_qZf(jhiH9U`zc@V$8p&s2`XuCy>W8tduuY(jwv8fcmZdq) z$3=#`T$R#E$ZBEBcxHX@J2f+_*ZSS~7IX-s--T{eGvT{`K+Vj_xoPcdsLNT8{ z_fB8%4hiu*Fd)uOY$Qi*i&|Do#RP?_YWcAdv?f0EFNRtyV@d~aGAdxfFl9qK!GeLi z;0gQlRQ9X8r4Fr%w4e%nSLUG~z7r<`8X_A1w$F}|mcQX3$cNljzUrw0W}kTBUAloy zR=jQGjAhqH0Or@~E=M2KTcdq#22^)N%(9!_qpP}%ilh*ag^@Cv0{gT4sQ8j}l*_n^ z!*g?U7_rrV#QGL$eNSh;{G4Ec8d&i$2eM9E&s;e&xKO(sdb~gS)Y$+{n9s}jU4)%) z|5Jaql}&4DTsvrL#$<3yr>_6f&A`7%-Ar9O z{UURv!VrB*MLQ_bD9h7@Ezx>=xsOy3=5E?_Ub|BObn3IM9bOLYA6b&cFD?yHZM!lC z(!igX0csq$KP#|j7Um}i@mz-CBBL)d%rAGn^u>4uaswW;NH~12L4-Q~=eU-z{XD0mSXQ=$%mT=HLxn!e$tl(2&iUR;t4#H8b zqm^S)+D&8aijM?oT|M2_9eXMfLtcH4x(bDEIK1TsNGt~nXJfqpp+9_t{!}pW=lo*{ z4Jzt^*bAF9(mj1UQ~foJ#X$gibp;4+2O)QJ4WP8t+Q;Gu!hV)Y_xR&!0;V~v zcplDL#K&XTS-z|&#A&P(ljNyEJ#QK7v#pWh^m8fbz3@BZ85v)Hk*pl#7qsb*DcMru8P4ypIXqm|OFSDUgICBqOwUS>Hx1={c3;-D&3@LMksu~9o zAqR5J1k(%POTQVu67?EpRS@CA+jY#Cnn1YFaybk!VBfJHah7}|4#nfJPi1KP_YjM;p%14Yuu8!=?|Z2S*~orEad zVf!c?5OTF3-=}9MUH=a=} z?&u$X1;gl({0c@M3$8zK(I~Kw{(lk+&zucF$JmJ7COxO_eC+0svt{!$_{!e=_6Q9^ zmT>Es97+;bpEHO^q7Y_0lTEh(E z5#WH)yCa^W08lSW{AkC+V91W>8>|p)$OA@wO^`HzNE8dL`k9k3TGG9+N6cYAq@i!4 zisl9RgKM;Lw~vu9zw*m&-8;8=fa?XZX-_okJx>3wk5Or8K}Oyx@0z0C96SP_S`!~h zkN8kknS*>Psk*x=8t@c>>+n*~?Wmf6i|HTyl42~fxwZ3tz4g{{bt!()GnH?d3{nrI z01a9~ld;^CKVt{;sD306UTEHs!9@qofk}`jP4s>#*i8J!q^Qe;B!8Dj>XmbxwSqX; zYl0clk#2r#A*?QzsotHK5wHJ9OcLX#o9#Gm10%)%V4U8s@^m-TuI8!au~W5uktrot zs|?j*0P1X6`yfE*`zh}BFHy(0rzZLPRdB9l`Z_t%>dTu?}n2iO!mJPohg;NQo5N9Wzda3U>yb6&Oo~(0_E@ zJqt&&Tc${bl_<=3fA1pXc0J1I#Y%dXB=?;5EZR|$c?<;x4&S3?o?5(lU()5SP|FZ< zFEx)Y`F+b3nzCIB=%hp(Hu9Pv@lcM7&Z2F{oeq4Hp1!|SXHei1Zu#V(U91#fVVncyp zNun&#EnLPOx*dXiUa6L94r%(l(X&|@R4~vc4jt->FwdhBfZp~RYBGUu-8rLR_}MPN zwpW?Scui(VXOMq0(Eox8TIu+o|4}XbG}w!FFiQE37oiD~WJ%J9Kh^Y39sP<`=&Q8< zi}r3&WkaQIwGPp`a6A>U2oT~xl<%4sbvz^Rx*$PheslC^!`(^OyxrWYeLoZgYVre54x3}D&X#e*3C50 z)LtYh_9~;wcM$XIcV}m+?1Z>SHUVdm@ThSJmPHa(=cq<0-X7WnpR_;G?H;Iq_Mj0vN7pXu+kB;&)F0;n)!favaJ!K`!U=Qp-k zN$HKhYL!x3dO%5&IB-H14^_`eX|-$1G05EhkTu0)~W?1j5pLJ_Jj$43I|aqSxMx zow-4XhJMJ$kGdw=^f`Cd{=SZsUpl{&;xh;9W~(N4`TXjw?Wu5i?S+#P=?#N;vZPX! z=<>&cdVA3KZ)i2P2RU7A2ctbhZby8DtAB49UR`0MBd7o1YO^(!&6@D0ZJxdb-E^d7 zCrvR?lrt63M>`$LlOpF9G?%uYjo4~BoHPGE3(diNg{fLA<21ReUjmvm-u~dlMPqw{ zByTf&{Qlcw!b!H1#1N$ps1YE7T%nT!)FglK)#_^Iv76+cZNRa#OI!5+O)RAT13QG8 zV?)v+{m0kBppWwa`Exc1pzw)Ii|$HDUeI?)VUHPt=TEufJHm`E0BGXCF~+}}6En&| ziBq>B(f>tTW-+sv{}o?ql9gjdn||`*;vLxB0e9cy`w$=*bBje&9w~N7PXO+$U*^na zZB*`8#aX0!`U#KvWKq-jt91_QUyHRr1gX0nvvAFEeJaTF{H6bXVBJ$#numn|vW}ST zv(gaFz$Zy2Wi+|nvIPhhT$EUf6?%PK)R=%)elz5%sGMl^t-=wMY(pnw8lJ*kfGjTh1-z&R9_su923PW6QoN)%dr_xtSZT?8}c-KNH@jrgU+B*1fx2ndG{Nxhh5 z9A1LMQ8x}Fo zYs?N`GR&H>@2YURFeRf2FpBA*0=1Z$yzrqZ*Px_nwDUD^oL38-Sxhe&-Nk3^{l0gR z9t*wB$snXD2Bv)cP#H|y=P5PN)`j=gPtP@Q8Z50$mg64iI1~7R&*o2Qp65)3@kqr{ zX6|2Sys`10u7jl!1fGjAlzs@F`OlVfyXUKPt+syFO2BWhHKVq0q(FYMbx{F?%s zs#*FjJnQcJ61-mJCWq;f0Pj|xd5V;mg(bd%mgi-=KaHi1rsyr|xwZ;@wCX=dv08U> zwYjkXbB*W)cHX^WEzTElk}3M#%=})6zkl+SE&nwt_VG@tatC;2yk)i7KS11Bv3+3! zpC>mB3w`DvJURE!1ll@L-Mt4VGjBGpV47MJminz7BCLbI{hiU(aQKkSs^T%~8R6Yu zozviy`_|1>A`~`!xWjRmYz`yEbO(wu-lZg33IY2z>7RsWPd}0A!;6{SCC6?zT&Miq z8SVQN!Q=VYur4tGCucdJ8|1+mB>LhlvbHcC7R<*b@@E9^O6xTy_FnSd$974dPTNiM zjg@aj_d-|A6Sek0X^S=0r_BXr$twOs0B%pnV_Np3$Tcl?+m3k0@pVo6pAEp{;%d*G z)kEaxzHrr0FItUvRjh@ewWlo_4@DaP-B33QWNaiSxG$}|yW5^MVOD-PyW?Dem#JAIu zd-$Wmsif3}Lg*uH5BEuhP6XM*TA_QW^vk5h?+ptoqE?IT!^3WqSyp6@qrqn(KBh(a zIt%u$0Er0D8Lwt!*wx0bj_fe|=3^m&=iGqK1Y}V6@goO9hXw8JDRTX)A)cp`=<5|8 zQX6F^Q9rn#qzwJES_pI$kiB+pLmc)9lBUkJ$m8t!I)DO4vEfJFiIjwG4m6X8FaTPw z4p-JE>ZADTM%Q2MyF*8if14iZQl)NoPn#kkZu_ZHvy^PW<6TLi_r={h(R5gz+~a!Q zG3+x63y9v`C40WyxY3N=wZ9%#{>YAym%Kj5sxOp{Uj0p_vFGq2^mb+hR(Gk}iIGC& ztxyafrwY9FWSRN*O1g4~TG~c++_-mA!%Od1kLwntFi82Rfm!CVciLfs#klyN>MO$B>W*_4Z$! zgXd>I=1SmU^rKcCdUp4IKI(+g_;>%mj^6^)GSQy==NgDb6YUpG3yBNs5I_Tae&O$) zQ4Q`71c!ezMm^Zcb}8kIijrd5Sm$D`Yx zL)Uw^HCk35tlI&Wv#Z7zy8})y;T6N5*UTbNk3lzfV>lgNq%zL5c7Wq+SBrL>g`*NDX#h|cDqX!Ke?G|5C0bbuG#L{KSO|JTgXEf$UXFN;_jDRL5Q!Z zRq$1Y>l=5oi7fjNsoj|~vO%&RWUGOU_R)S-X?hTQ*6>EfwT4bYQNe@w@D;b!V;e3VxO3B~;VY251{2E@a{ zRNHkm^0jH9`OB|A@J4%@Fry8*gv-8Os~%?_%LetYZ+|n9&^lY$-HRj}9otU>wNzVl zK6a@Jwd>m*rXu`c3aR3G49D5D8Z#{K0Vp|8Xs{Z!#xSNY8g$mPC6jtMXOaV9jarZJ zb9XFrKpyr#A1oA8GXU?<#m<;hBuX#Rzb5*Md_m2#WF6y)ktWS9)kUSX+b~N?1vcL~ zdGy!I!}qIAhFhFSXir~>^67)20p2+U z)LM$tl_B2r>xbE9f8*#1WtzSp#US_eNb^4{T*YMkDTTx=NTH}rU)n1ABQ|WBLcQPM zREtnelQVQK>s-bgf{&CJa>@6(56fL0#lx3N@6Owch_{M@13l_2X-saPp-8$3x*lx- zR$}>oNpC~!gtGXs^Qj1pz+?EU{YN+~8hAPFoC`AphcNFTd8@C5hY5x?>cI8C%l5}C zPV$9nkZSy9N@>3E#Q_$YyPuhbO?h(A(brszKjKz*Gd~!Qg?~MX!@4U6aV?hh%tz9uLpJjf;lo~0+zOT_<=F~@trd3lM?G)xMWh5`pxxcymH-v8? zU@|T?BUw(DzSy`}&A3KU-LU;?z^?9Y?XJE0ez#rc+hozg(WZAsJ$KM|JBl-EC*wWD zP>Jzb7&f}}t=qk(Z)j_D)s4he4-S$n4G8BH$6nwlP)}+q$WWdbe*44S;Gv4t;_9V4 zw1!A0W=Nan0~sN{ddZU@O25|UfLY~kkN%c{%Qy8kc095GKk50(-8lHavzmX$c}nLl zz8)tJ+!toeXggNi_B5s@C+;QuhTXvN^?!iSOQEM!P`}FgFOFNStjDdNMHDIB>NXM4 z3y5P3sbH{#6^-g-Kab$7`T=YT)Clr5;z%xBrj@Z!JGNmfmYglsqGK}PhRC~okWnkn zmH|CToL{2b3iD^|FRZl-4ywd?(wT9Z>e1f-9lS3wMNVYakL8Pk73oC5=4F7Lmd9nFFvAxceP`01Q$jba~mzB5r@^+%S!t>x&Cw2^a-q(9@RkN?RJYfQCsu}H>n>E+s5F<>v|_)p30DKd2ghXx}+A> z*LObMEZO4u1e)-ADH{R@D>d+P5NAYdP6qI9gct?eH!a3ri=!RX#<0V9Pu6m|JA~8~ zkB>0i)e0Bdo8A@s>}stk6uz?9q0$a1YRowu8+!VES>_ElKKyF_<6yO%fsjsPuh^2I z7Nw=M^?|#*>nc0+jL2tQY5sQB_QP*+i%ZSB`MQb8$LNvq2LaJaRgSLS1wEXm%p-*R z_Vu!xqw?viTAy|0rs9^19=rG!+h>E|T2^mo6AMsu$1X+1vCtgXkz_n#EJJah=4zM(w%wcaokInV)nDMC`7Zasv zTrTy*ZoK?4gTMH|f?O_rJK|8UGO|%W?``;|r|0|I>5YN;DLgZG>B@K>g&@5#hs(de z`{}NNXTDWl=17Tu<#+ToxTitdzdV$>-7$S)v4?AMVEVr4fu)an`p4t`rJ^uMF1trY z+)3vqyuk3SmHTrH#w?&@4vXMciqqE!A8J4`>f7(~fhlgJL1<4Q^)N*`XY2I=Op(?R ztwp+}P}EDDmQ7|4b+l0ZO*L4hjEr&2PS7ASEfYs&$arW)weEnt0d3(yRSS{~X3c-C zQ^MXN=A>}_zX<2M+)QM}U9>3n4A)FF(|Y$p1c+D%E|Fl2YD8FW{$;?3I!wCDk9x>h7q92 zvI?Irmy*y!@%IKuH~$@4FeP!9UE+0EX2mj=*! z`-sklaRP+&P?Q=aW7U&@`FlAWD|mgq0AM=}g(H?o$eN^olv5f;qERgQN$b8md$<(3 zN+(lP25?_UdcZ+G$bYS;4PvQS;e%cof}h;cV?&(8o<1gi5fkv$KEz;-zYEYWnAEm_ z$UjD&q;hM5(S4U@KC9+r2QV?yLr^4cc11x3PaL91v#~5p6ezRL>Q#TrgeFl%7JMUt zZy}sPl4;o#Xxq0NfT9*xoC+T;f|iBPqlbWPV=rrq&&xZ2*Fjh)PkfXi`HQoDytM2I z0P;pY{Z4iy6vH$1`&@QuA8}&@$DiA)bWd8zIHtC#krk{x9rdQ_YO^fUIUbz|*ojIFc7lnAZukwatzubSr(Y!IgnZHlbo=$UDbpmcK9j%O=TbF|QEnFaznvorhKEqXiA3*Vt3>-w(2J zOGd1nnGCb>X`bv@h zSP@6AIwF?}xmUrJ=Gn%>!9Um{ZYOm?TWQk<+lTIXw&mw%gipTz@UsywC_YM~0XFpX z6|$|_lIXb2AW@GL^=(3F$fFh)X=*v}F~0i2*q^lRv=b_oqkednM+~2OD8#<=vjnFX z{~_GceGyerKuucvA)S?d^IP8&1km%Uj&SukQ_7+qCM!fpHq^)Cl;fuKYbe}5iQhM6 zm@^T6{!jO_31+`PIELNcn9OPw%jCSaSqa6lG^PL?^UC$Nm=b=8nJF(wRZENnmRJ6etb|WUkD*_2_Ejb+ z^m!G|ew_bDo4NcnxTk`7x@zUyTCw)M_sRS{aMp0LGqecM5r@z+?c;!I#>{2UUbdtq zGOz7;O7r~_5`xz+FvU%(M$`)xVo=m=a5ry{xB}{X^0fptUV^X8YpKde{kqSwBI(wAw@;)cw#pT2zQJ;X8%^$`! z$=M}}sCUy>l7~D+*@{urVQfq>R>3ZVNe*5)TrR0&z><7>ttJ{ep?2>a%PKiAyEvkd z=9A_;K_f;7cLdh(wE=QJ>jOQ^?d&arNJUkVCh+n@-@5c-Ft*MC>}Qs&ST#s4(hFYy zv{q#AD@1?e12+R`oh6=|C_rDD?+2k%^fnk_V(ti5r}``VjwyfDR(=98$~%MUE=rt< zIn1o;iPd9bYD>s72cvHYcdPh-t4TUi!l~Nv)|#hw%g=hFqn3Z|M6ff^|92RNnya=3 zN}m4%g+O}0aJREMO6{yzf|=G4GDiH?EM}m$L=rq!f>Vf+Yr(Uw zQ-zY&f#(!T)=dk5!|!A%Jpjii)|A>=dC}lqx|c01`x>i_9136;x%Cz6QaIWe@Ez=A z-TuTa3E81R(@pWkP$x88>LY0VhUx~>)?oAwJ`z*LZa9fR;37&P06v|){GgqPf%HEmuju1pad|ZUYn%KpDvqcDE zNkm*699(_>FQV%G<|&=Y*!>AzGyV3lB$TJ8yJzB{v0a7osHafO;5R(UA%-qurn6p8 zu3Dn9O<&b`i^zCG+(z*>Xl*_pkF(-jSyGfVuS4X=1omQPFAp6!b10L+Cw^)lPY8Uj z1E2Ua$P~&r2M&SLLa*PYhqkdb=(?RZVLQQb;eslA^XA?g0d(In@dPN%{PX1Fk?_^N87r?^{NAc^)S_@h7nV zReHgzQ1nS2WV5QNtzE1%0qcc@gT37{Jl_4d z%;7MrlQq9x?r@w{um7gpj$j7o)AXkB{T`fJ^!#^y0;9VG{Vnfnfh7=FOf1Z~FEQ%s%R*p-2vzEz&NkriLE7H`%lG5-2bp zW|gJiN1^4Na&l`?Iy+Mi`tP91?$_%i@>AKjdo-Bm)lv zFV1SD0E;V0?&PdDsR8k+)I-D5RwX)sg=oJ+8L9P^nmC&gBNi$mGFF1|<8AHyu10)E zaEqsf`^MY1Z{NLp_d$67{{4qZ;1dUl9A;$kf`|=L^pD_vD;e?H#3PO5YA0Dl z^q9)C1Y%nlmbf@g*lYgn{df1@y({j9yul>!iG#xadEk8@c=g&Ii$znoJPjE{VCbbR z4QSe(cG)nw1{s^aAa_=w0J`u#&WffxC&*%0k|{bB{hSJ8!B}Z#Lk?Dg3k8&-cX-+l+M$P z>&hNXOVU=xlGWXP9yon>jS@6ro4~Ur(+m!W zgRr<|SsJe;E!ujH=^I{;UQJz;K>G2+-z5=Uw@dw>ievRp-&Rrq{xG4PaIhDTGY0=8 z;QcKg5GR*P0^;I?n)fR!5N-^X;t;v35mNNwYQjJG5{1t>@QDLQ2%H9-mzZ?mT8IK` zA3h9Od;gA>8mHjpS^8*nnAxSb^HQn0d#2Se)0 zNv{KU7k&{O=S^l_WYUAD319Ji4=H$O@u6Ke&{w&xX>GdA1F14N5xOdF)S<_&tsC zw%%pV4t(N3vHhglr?XH_!QXy8DgKtQV?ofsT#r1F!Nhl%yr6(8N+Ezk8a0K|=b;3> z0!6o!QY5E5dIIe{L2-TpMkN-D zt8NfYOo}>rs4gNxfE;&^q&fkTOh8p#vnjqx%)(9=r3w#eZHwBgI93{0(R(0?7(%g4 z=?uhGOKkHyQi>s{B^nB{VSl_Jf@lFWQyIfNl6!f*Mq8)sjTEHMe7-r z$x{KWU6NpDHjMs^3kQ|Qy3oH0oM#fUGUB@mr~jQOBU&K}&DM5h;D2QA+*YHhf+#$A zLdAGMjWG~(A_&?l4idpA!C~Ge3Q8e_=a<0eiPHzf%lh~{2&hkjwDC3MSKY|wD*`O+h}rc7sL{} zDA8(VAEB93`U<}@4@|BHaX&#SrtsGh zaaH^e@!2@NivR2S1MD+w=tDd~C=8br6*+++9N7=88-S@en5WjHr*#t%) z==+r@qUYejqK4nqG&}bynm=G*SE&CL+D=l(xXO`#h!w)$MAz-l=(H)hksm&JQ;bPT{~}7$ zU0R8Pe|pcR!k&YFvJ27t-eHxQVSOb*iwWnAUTD`F!JmMrXwdXa#- zo;Ku}QzwR4p|Sj=fO%k*LN&`v`X9`6J2t6+2ugzrp8>){$6A8IG-DZ-a}TJc6_&&A z@7XTibMUV>0pD;w%nW=4+;EbrScY+7JdXPMbQ`BABZkCgxQq3S$f%{X8 zcKy4T4Pp+45WEq^Y=qm`bVd$hco{%O;tV_%0q^fJFMOg$px`6n%)wa`#}Ay9qv7zl z5YCb5!DQF+PXgjm&`E5Vl4KfA<5=>&{BPjH(|Nr{u9q_gR<(G~f*xVVqVVRfbb0@+~N{~_M^=O21CWULfEM5UKz5RL&7?PLft z5oaZZFEc;=G*fWr;G2MNI4j(RqJYo4to%u}47UqL#Q{Mk;Vy`{Qi4f3p&Pr^Z`CLe2|AkuZUtv$tVrzp%~Ic*pi+ru~kTF`P9!&Pu3obNIf@Nb{ku;FaDzW%M> zG_F^npvbOeJHgIOoCMY-a8E*8$rAU}+c~$Vprn$gZFZqNS9bmCsx{DgwYt0DBi01S zH=JRNep61ut%Nae24zOWeMxu*=+~r_<-E%oDQ|{!Bkm;POlE*oIMQ7m1X|QuN7)HJ zekvF~_^}5gkM$!0{`O_OplTRPQ&3k)#>=%*#>7XZlTf<SG0ppD-@w(&}XxHg|zIPD__T;&f7jC8h}z7D9YU zIyRJE?*ew0PXVq8@&!u>S0c~Cav}!(c#1H+ute|WamkE-<8?_nG`V56I&W`Ntv_^0 zgmzORp8%q4CuB${KwX0AY4``3g5UF0=Eo86ubPu@yNH?AL_SHZ&!s#q0FiU>GED9%Pn2jwSCT;kpP2toPTGMEzvLAmjy z64W|4XQtrTT1gQRaIs`8s`yOEQS%(lO1L=)^y+!!Jj^r+$6Xx^ZUvk~$nR3tq}g1p zV#mi?Bjkb@yExJp#|7Rh@H9~HPeH}Ln0K3fOd9rkI%0?{OZT`~uQBf8Dn$16nB?_2 z?DU$sPsB(yKr5;2q`Yfj>K@@$P`dn3945(t8utrww=iV6)H%4-$I{GA!X(Dq1|&Un zC8*=hR9FVMOjN8C{_WS<<|KROe~_?H{PR$LOwZ^ zJ-B)LxoCHerxo#wi+MLnFiRa`;D9P>V7zwKyd)w>7~8?JaIJ9WZ!Ap% z;#nHK?DvTg?FjJ_v%v3!qOMP?x@%>e`Ywi%r=6?!;g*)ZhfLQa-KPy3qiaZyVb@eI zWmfk4B;u_=ITe3%z?y|X;wh^9etEf2*nuB81=+2fz|XHi-Ve8lK@rV&wdO-sQnHAW zhw^zh3eRMIe*Nkb8#bH?-&{w&SKGjQ1to`*>==L5*d=ji-_D@nQFo4N?e8d)PXwh# zADG|j(I?!Y-eQ#6hkK0sA)bk#%K1Fn6Khbi-`Xt`6}(Wm;b$^0ly5epT;v=P7x5x` z`(6%oH&Dq*vf7e^WD-_#!d3V5)6^VKr;c#Cj3=d+>yGMU1T*Tr-DV{emL-KaVw(5_ zS=2K1p!)m-Y-D!H@%~bGl|-m(+2sxvo8l7BRcTnLsP*A3)f7m<_j-nGCEODaOjmVa zPN!*n(07{Dg&YwiDJvncFs$4d=s8 z^Bv~A3Wa%w57I|scl`tOyF`#)N>P$Qzs4#fH8@elK!>jDhWjp*jCrkziIQ)XTqjew z&GSw|2v!MqQqCxcH*zT@MW5$MrZ=OAOu`j$(egc5+DFR`BqpFsX|W&~cpbJuml4)d zQcz@-v^NXl3}|XluPB>PXWc#`3B7%)_MQ&^9vRhM(@{6<;YfKnW>2AbqCM?6I8D$s zY~=0%%!#`NLqpX{z!Cb`AaTaKi6IJd`0_qigld-%e1d>J9f0SPEB3Sfn1OlhB#?Po z6;*ylG`w#Q=8^9Ob9a09dNypiFWi@-+~3z$lx-1w!`blVe1{phf)e?zZ74o&iG25V zZ+8*~NubK}W5zo|7)5eD#iq*$j;~>!_kq9ioZnT>BRobOG6&J{oG~6oB!pDE&MoOQS}HYAo?pW!vS)P{C|19gxlh zV@g{abO3|FV`fTo;etc`orIg0>VVk{TBWuOBI+@hERef({BbWzzRKj0%=_!Fp4hPA zEO-?GUp>d;44mcfoHgZUsN7KFsJa|W6Andp!tFu}+#?a^EPglLDb1W%mcnt0Gf!WR zu1B|fT5YpN%?|LgvgQl+aP3WG`z248!m}3cOdJibZ7D}WUZ!DY6k9XpX`9rv$&EOa z^%bn?$1aEW-aAZvc?~5#q;z=gKxxT193uMw(N5PP7o`|_IE0%&RI7@80Jv~%x+%fT z;X2jyV0aRC{b}q^r}QQK&{ZCxmwHcN7X+Q<%0%tzU4ar-Q>qboQwL zHWb#oQCtWA{QCPRHf%Txez^V=%o2Dnp+vtUvMy~u!|yh*TQG;~C#A+dfJu&QKc4Bz zx4R8V21zuik^{`$cdKpQvv2}QD4c9-j9aYzs?&PssJ37e;-cIy-592nIrvM=!Lu5E zgL3B(K4Q-iU`|1amfLEelNZz4-V{S9i>D$TZrK6vzOIA_>ktEQs8(!vL>!8FyxR0@ zRY8zLxF$p-mGWyP;U=gNF)($NRqShVjzxo?JAt0dY`{!qMNeZI_(P}bgVAj-+aFc9 zU}o}7I@OX+iX5PXroXj!Mcgfiw^vt>TTy~6f`9$v6B{<10og>DO(^gD>N`vx-hgdc^ zBiyk;t@5V3Yzy*x0#?M6M$&N3 zrw9_kTcj!a!A~BZT%LR~fi-%u$;B7qNyQv2tK@htg+OaMeqcAwWlh3sB^&{-Dfs6f zZ&UCMX8;rM`9aLm@7aX%avr3AD`v;*taj+~&b{C0auOxc+B$n*N>bI;dkvu!SF&xo z1w0tNmg8E=eNz=tFK!p1MNVGg1o2OC#Z%GmXOdq+n1nBvd2kf{b_&iaIPWIhR>3!%13z4U{_B^4E8o0}kORGT z_Ra8x;BiPa`52hLgQ;3G(n7v-FuN$*MGV`qe=x_qhA_jJu$ZKnQ}5?g@aN1q32)VI zBPrZGK8JZ@U}OKpLlk^~e51}`>@vi1mVje~NI{SuVk4F~Cj=P4Cn?nPKgQf1V1wtM zzleg{G4}#C)}&BFjmNgYTO?BVV-q|df=`u>)QVLuRji;qG>4!6$F~dr5l>AbJmWwX zOje`Nspw`2z0u@iTkp)}vW7hT%7~38FQPu}MacacI*f z@{QmCiHO)F4mc72|97~r=UUI)R$lG1-=*(W_3Ufe-(Bjhs&#)KsLEp1X1c3^9oZ5PC}{=A{ZJENu2S^%Q#e%zquup&U1qnC3C_XOcbgojVhxP zC)9B_S_hHukQ+Jrh9OCy6Qi9;UM-Jn7R%$Ako(K#9MR-lJp}&#VymzG=6Mn28UJGZ zlm(RYU9#{uH$Hfe?Bzj@jy065=Nk7g@xj!(+K@H8O;ud9wFLu_5;!PhE4mx}^l5q{ zcfAgrOjNlWO&*oC6DHdFb}2oK?GEwCAWk|)*Hn@o_U?*G`WQ^iv7vZ8L_9TBlf_Py zo~%HVvucX*7uaU=ZpK^m6rN6%>Qz@!#jtdwtbMrSt4Z1=?I<+FJ6exC!;jjX#AEYP zGM$Uxpp(2|V1luhr8DpbULS)OCQ8vwO2VUvv4_M1|LI_pMwZf(IhJO~6G!Fkn8u7B z?EJ~drEq25Hq>n|2#|6K#oZ}6;q>p>S>spL+uC{*l@@DT!fS0x;Q1>k*{JuG~eb{oMgI!-`3B1y>mPF}tX z$>N2Gmu|;!?tX_4`KL7#Gm1udVmq`lsQ|10*8uxI0zHBIDLq2X7WW2_h{mg0q1J@` z?>~7j%j=TC91ceUbfWhpjF>vA48)=}gBo#@nq(mHJOpn@MKK5v2#L%6IF!yGgU3(a zK0ZqL5tLCyQG$;#;ygM_kBohTpP(r8L8PBJ-`mrBi2L6%e-bv8(NEaE&e3L(vKNV#(}HH)CbV{ z>VzK?kHkY|U_c^_53hr0f_Mq?PJpEH?V2++XBz&N^P1UC=+}Mrdhpn%a|=Y`x=yb5tMBS#XR5W~FjO7cEqSIfx-QFct7Dhy~pt^vUu z91hmHGZrAj`plSP$>F74ojc`*Oj0~!1q0Q-p?%}#%EpKqnVDPGR3YVS1}J)$W2&sd z;VC3(Sv;BOp?H0H7i(!5sglN(XAo!I}I&r0Y zAjx-Ho@Dw2USTo-+m)EL?qv3Kgv4*hMFO;jV%fGM`MXm0%S+D2T3pE4q3;G%N}5$c zGwXzCO=8CcI~NC!X_=%?+05W&^<`)z9f6X9G4bq9^EW|B$1HrUugU!TzDX&ag0-9bq_N|wgLJhq7= z4w5D;Z>MH2C_K_YNtrWn$$A9LjmgtHWHHQ0vjjQWafAjothiClL@rR#h|4A26;BRN(9HXBV@bGKIES$vL zD+q-rc$`?9+7CO-eif|14JZ@`gbj2zN=)YS@6TL%4dIB&POgOsJ$IwP?9s-sbVA-; zLJ8Y0$1`n?6)CSLH^Q@-2rA)lNO~wbuH2kg;bx#D-sw0YX&Hysy@(Y1SP|&S`dDUM zUO^W+B!r_&+%kK#bRD}e4kb&&`XNQN1WDMzC}|qQwGS9wBBlmN++BK6D?8ALnh`!F zZoL(7w2lku#W?=9DPU~bUe=b`qoHwU)sF-p=v&?^dALSfa!?>Usc+mhSB;;woZN^tZ~(RqS6 zax-Mg51KMInzYo7PBH@dYI0C=;QW}D4C^X86{p2cF(W1vgJ*L9T{Xhd==BEXYuzrh zH;N-H5(@wQN1pMFe-?db_4^0k`QE|bT|n^{%=8C-!}RvRnSpy%?29n)iKWF0J2T z@e~JTn@M6x+D9evM#VRV61zYm%cH0@C0B3JNU_0arG5(t>e>ru`9>V=ns*srZ4`Yg ztv}xHR2vVHhQXac!`OHTsh3AXR2L1%^09qKkpT1W^qU(d;q!$b3H-Io;K#jI?+yz8 z-RGXC;m`PIu=lTjKJ+bry8zxj;NYb*aKOCVwG*hjt0j;#E;^oim6(9@?Op$FlGOXc zi+9G#*>v*hAQGvwKlQhB_fn zTgFxr)JUgo#`ST=m4L5ct{J`Cn&W~-X76DcTTQqw*>eePC)|_hl8LirBf+`|BTlkY zXs%3-#4nu}OVujhb&cfYY1GEFkoUR(7n1guOH3B1(`Qwj2{}l4LdniNd(WCuMia@z zTQ(;u@K7{$v({>fo3tC&!M!>BVJ-)E5!_YqZ@P*Cg~Q>$_`}bhS^OD)H-7rFKl~!> zEq!K{UjNga>=o)o?R{Y4M2A z&b&_?o(D&PLyciV^L9~srqP8~;UOMh*?eVF7HVS2pjsqc4T-kE`F1QcPa?1~t$P|N z?BrXUlrg$Mm+Z%G;{m>1l%9{Zkqy%{@5!4)!P40BG%Vi5ld-3utKKvq(-XdCZcUe7 zux&+aZ)k6CTm}z?KRo1@%zWKp89Ws3FPVHMGhbPI#xwpw$llO*`MV1!(6?I%L8$rR z60Yr)RkY8z91cC3h+8sej_nYXOm0I=4grw3ABVt2r{OYnIXvBsDrpJYdjH_)T_(Yl zsMno1XDSaq*PgB5(6TH}h;@>3d15I^L&M9dbPYQ(WNbYAfhk7nAeY)z4DSjGFk|&K zCwG0j8&s~dhc_0)9_?d=$@?|5q+?wt-KaE55zs2uE|HkiJS~+JI1qhk?Yj780Et9_In~w7yOxJF;qPN7 zVU`}*3_eHdhrpq5mr=g+ou33h{J}Gg{~eTd6{x(*G>z6uYXZ#^AX=g}M*?M6h{llr zHu%r-hmiNT)35ovTL_PftRB3F@ZzO|wihum*dOi}FY#dJD5)7jN8lziK`XGtSj8p0 z?N;!#61bKPFv(Ue&CA`YGz_339;yE+^P^tH#R8c7wc{cA&AcM!LCdssxjOY&vT%}8 zFGlKIP{#xq1g!2MpDQ?#FM$`OYEqa+-WT6(MB*)U5=Af)D{81&z4VHMM>IXM^oot! z6%cE2$skQ{ZIW7DLTLpX5lb+{`bfK0(pvf>#n|bf1c+WO;@U}Y-I;L zX;ok2uPFFcjVjTE?Ht^2TLe$DKFymuJvGnc*Jzn&=z8cDtf+5Qo1()Et*U!|Y}^0x zKZ!#2zUJ@Uf#L#qFduDTy1id4zMyD(@^VZjN45qNteb3Y3Uw}>b{CDv>!<6i zFqgTpuK-*U5y;Bl#i$W@_OnOs(e>M0Bom9nD&iX;JY z(YTn>HNqfKM-K%>h3Ay5^GY)?^9 z^i8z?d;IFd!+PaXLk*mmRg|flZoK9y(W27zwsq~ACKlfdx_;6hFuMXCc!($b`f#~~ zy@#a(EN}gmv9T_jk+u^}ty6E^2)P;ynIuJBN%4z3d+btK@^;S&&^LsG;S+e@>;|R@ zd~8UW$v0f>L7ONNFm^mlV{xYhNbL}Ia_J0NiXN8tgb&&f(y?(26WoB_s%3A7D0o2^ zO{8mMCYw{3uXzo*THY>?$3RNm!`-p*6ZB{fkgOAkC52;xsKh6P>ZCnRhhi36g*p%L z))aju(`Ev)Hb(Rsv7l^;944g%Iqp3MG@T~xqn(Ju% zx=J{NPz!VxHS5-PP0``W#rEdw`4Jbi>!t@yZ!;q^)-2?S=D@s~S z5@WgD1PjyE6oHvhyW~wXz8?B=z54{{akZ6HTsB|&R#hLW`v^N9hn3U9%(6L*&88Gt zw%1t&1U|NOJI5_YoDb$iFot?boD60(Lyyb={-3CR>9=N4biG8wUg%g+NYrcRSz!vH zi=I*`HC7}rFICIlrD9@T&PJ-;FJ5*Q>LeL&S~RVE8t|^zdriPd5AIN*8?Ln^R#FOl zg?D6?w9&ViB37N6&0WL$uJ{@Aqc=>!{aC$w2?N)`12`N8fA<49JR~0R*An=5c%Fzq zJ*LU>{nKdC-!f=P|HEN%%eCz~yvqm=Z5-P^br2n~mfl7U#?_|ljwg(_En4tj^7q7l zF>?2L`w4LV-3d6~!F+Hv+X*(f#Grd#r{^ub^*h$iwk2k#})&*m@SY zqY!be1Eeuiu|rg{x-J&z3ORi;>v4nOZ4X=)_$Kbo6yJO;O&7a$y9))81Wk@d2T8a& z5HM1Go=eN^7~NY^PH&;zoT|lEsWd&0sf_Ew$?~)(=OyZh)O$Whwsz2N`yM|D>brt6 zwlKYZsz+^KjFs%1L9 z=5Q}7EygIsFq09u>C~nKmtqj<=($rv4rF`Z8t!%>f)!;It!PzwdjQJ#&rWqSvt)zxXDZi$kFuiT-7GH714=8 z(i4M)-Fo6|*^v-5Q8$6Z)oCd%Eho7Y-@`cl1#^^X%gf;18g4Hjt)JR$+s!lL(%;@^tzeQ2JhXlxDBTIh8~rZs5=24A};#VrQ5x| zp9+t#c*X3RN-DzbZY4ApBt68=V0c?MBF2QqA`%-gggCA1+r%a6?hv=4oQ}3$6lQMi z+$TsGV#Bhtj@7*o#W;YH@9u9WW1?f^+Fc`urgw^-sI(nmMCc@roDdNq?9N914p}y>oFB z6gFZ}Mc+`g_CJr3{Xh8E#{xUz>3Ya@OMPtW;M+~46yJ=7k5vYu@Q5~z5G8L{zr8_r z`P(z}&0b@J_r%TnSlgz_K>3hv`jGLxY^j#G+#jT=9;xoa`Z=(0B|Sgjp-3WEC>a zK`3lty#}q5@(n@SICLl3?gw-RUV67!ZZ0flGToHSE<(pdTtA82q#TPIJCA(5;l%eu zk5cY6OUJBQX9wvD>lJE$wluDaD~<2(k5t^dR`M9%qddLuB+)$TV_$d%kRt3{J;Cly zai*S#;MM0{hAPRtXm|w+C}JqwRd82PUe9I}X5l9aPXq6Qtc$1r0QCb%oepq{cSO?C zo6cN54X>ge%KQTN%KlE(=yzBG-j*|#q!V~ILf444g>j=W-^X~;RKkk5wNZ%3jw?)1 z*MHmz{hvb8E>24)-F?#Kll>5Vo`=Dwxk&cn7aXANJ)YwfoFJ~v3khkdq7lp^OUGIE zHmNavxPDJ!_FHO5Z5$)|Lc3ouR)VgPIPHEVNn1LKzI=@!N(q}voNCAJiib3uj61bS z5XO<;G2@^DUQkNT%i>GUJg5`O>SVMU>S9QXgtlB>SlTOP(ZbT2QncQlyqWqkdeJLW z%CcsPprdlJcb>D~%$ zx_?TdEh*(;jPTi<*zqHtfTJ~vzNxZC$8C}3bwQmPPce7N`+E7?6%_9Q537pHMP`7+p>f1ex4ydp_k0c(+LY@jDcPMVM0{cIyef0|B^}`F2`L;>7_kOk zF0ID{5_trba=f$WsJOu2IK@=X%(^TcfR3l@Ruq{#KtaP2cMOl)tBEEpgHsb|MU0p1g^%#0yE0O$KAq)p&Ey=< z0N_MgSS2@yX!9Uw5pc5U&^M-sGLcEa$rQoYz&pQ_Puy4xX;_mdUf$Bn2p#73gHMM7H+5|x=kxPP|L1UvzA)3h1CAX|7LuB zAeOyL-w-%oad7_qzykQe-}7@NO(A*HV-lJf^j?6HA!-rQH$kHsW*3;G#=dw7%5pM9 zt``u&CTgTov3FagrQ-#WrE_=~<%W|HE_EC5>MBgW)c{{h(0(emyd&cSn*?b0_gd3>_v;@28VJ$&+qQdXBX-Zg@Ib%|xVm+)}v- z8a2cr_B6{;#tTz7ljx=3>F_tpC@OArgG=H-7Dr0uFnPF~=KnUa<`M9` ziVXE%&kZY@B)&-m%K@eM)L21R3I{b%boBoW;QxOT-c=!xhpK3xH3~GHrcXCJrMfH4 zp<3S%PXmSjWr4e;?`}Wo29)4y51-~I%NH-4UV8<&*^CnMw9^SG-~GKms2fwVT5+ zN0LJ4Q3sbUlt9#L7X>>~wknp4JsJa#4&AB`hTduSUW@`T{Wv`eruM26zo`}#nb`6- zc@|nM*v}wK#iRi2twfRB>kCN$)Agx9)aeGo!lZGmRw~fS_Ezvv72tic2#k+#uazbYxHm;*dzGy)D-YwzGq8$WLOXxd;-dn5e)Dx4y zg`A$=BbX{{_LvVst-JyZ4q)(``5iO;gO?8<>?9lp=W-MviGSDSlqGRh@!^@jZN%4~ zimX!CMjtk=M#SEiocDMWTmIf%DZ1C6yfkpl z!_$Ku(ge*#lc+AHAXSe|hQ4L-J$N5`IJcrdZpy`;rUzB$#d0iK)5X0xlXgWYoPI2W z!{IXtf0*4UY$!aA)|bSk@$c_+JT2ewiOOS8+g4yJYt(6^vHwoMM76K8HEQ(_3D7lQ zR%MJR!XlsgClIK0Et~H+G^zohIOH?A* ztM=#)g=j(vxP5Rk-SSN%ro=OTVamM=BpAcpUFH^2`<-RoHt40Y{j6F)y`zwx6Nh+> zY-TU3B0p44z%_5@+Xg(58B^k9IVQ>s(pk2-rS8(TkBpy3sEdx#xYa-zcdEac8fdKj z#(c_%=*!OrY(5gUj5)Omd^AA}BRk^;hI?*4hGg6|863+yluiYYSS>B@yn4rkZyT}H z!M)JRpD*8kh{v@N2zCaSd#74DXKs$w%L%Wzd2qc5-qILG>UASZr{Hh14(nsuIveD`|f z(*;lD|8;*8YSpY=-$>DfUAA8@rZ(z29MuAZzz~)iS_`e!PzSf@{BPWQ_AY;e(D%#C zzVjTUD|187Gw+U;q%SBrJsE0=_R843sESB{i>IwbwZJ8m!rv@ahs{&qd}5cr&nI{k zJxmWlyNZGzWg$H6%~pHLp4Ac9*Ts}wIU$zNa(R3hQro@V(MXrgmx*HqXw$`Bq(U(! z7P86mvvy%gKix)6`CRL?6-5IWgKjO9bQdN0z-4gq`-=4@tJO8_%D|M&ZMVDB0vPKT^dH(doND$MNmZiTBSI08!wf5hqSvhuqj+!I~vnGmLG7dOnp;qQ;$ ze8YRK{K_$KAzZ-V2&dxTJ;*%9k1)QQCQFCl6AR+RM8rk_M-h+2jR1_MC~Yi(TeNik zfg@ry%%h=nv|ebxDGPP&5z|^xy9fc1IsSl9GwQG?F!S9!WH6D6&Os+GaD*=HX|zu9 zZ^G9Yr`N8~X12TLK?2`Tq1pjt;zdP{yWptKbwJ+feYyB`dS1r4u7X18Ttt zwE%NKjKBN9on-G;aBSDOafigs?_x@iW1`Sj#3+NCnQ3u1faAcoe&ZpX%*17G=js0b zi0P!9QPMTG+rHZYCsQ1{;ty*|EK1_6j$SfWO^L$TpOZzWBzAV-n6MdU)6(z(NXmo%22N z_&*EZP0J&Ggco;ONE$_r0mK@C#SFp)rl569g;ebTylHZ^FHXlcgUu_)io)Ar#4Juw z!xlKu2Wt%xtw;Q;8=__$_&q!xUp`Nh^ih;Qqq13E&jEF6w#s&RE%9CY)W zdK#_hO9KDHJn{D%;qm!2=Fpc1zFGW22KP5i$vfs{_*?_+o|IkO29(9y{eu{(Bpzt< zhzWBS%AUT2s;`ik0=?w!kxXQI1VoSJOxrjN92-5|-6V5xXHtG_hmvq2VJRPGw=@Mk zxq}y(2+pf1f_2g4DOBc>;sF)Q;e>@1yv+`?-%8Mhh1bDXw}yFz6n~A~3JZ-lYy~MhwyPVP@9Xw9t1;wt!mOG+Lh(!F zdCN|lcflM#I$3GBbZlu!d&GSU1XlHZBbI60=N}RV54f4oWA)jM;yOyN35Ud&#b19Z zEjmmNm2U&9>L4v>sjg|0iuxz-AAyWfucnT+ZGtKnR@_553N2xJeXzeB=#a5NQVQ!wX_s+iAK$sRFUP#Tj(U<+0 zF|J20aXgJPF{n6t7g1zznLDkE-zfpUokYz2D*awUD2Jnj`gC>d9I2;&0wHxHP9JvA z0`R$u){?kmu_bX=JMg^t3U-#-u_0Gn>M|SeVI0J1r#GIwObsT@O7krQc*S`MynwCO zOuDPu_~)N6g;}^YP#%0qPVy!!rJJ+@$K25FWKCO5jT#}k;iS~u13TN)iVm%H(6lpZ zi}gvY)2m~n=uj%c1DUR0)8TFm&uSV539C+vDGD9qsM3A&PtL<3?+BN{(_XxN%XXCA zQ5eN6JUHk(qJYVbr62#}+yGjlM)N;i1T`Q!9tncxEt`;!1>m)Ywb;TKjd7G2Fh>(c z|62URqa=Q%T1(ZE{(UetO5Gx}mf@Kf##+$S5iaV~zD5gb(f)e2PJU?5p1f5ok^p~0Z2Vit0ZjPGcK-a`}^fWqCTDL!S42Mar&Yf zO?E~E?lD0-7oW6p56UN>IP6S3n$ga%$;@0`Htu#5SzNY_#$brg&^#gzf)q_>ObsH& zOa8L3e%)e+m{B+4%-Jwnat9{v&)ZPYRQEC~iI%t|9@(W4wWfvJsgc_43~z>1%g`EH z$n$-PRecRfV`yGS4;diGHLJ8hC1P$Zf0qg!gv!Dv*uVahMgP3NGW{E{Qci7X zynWVhiQ%$wBn`7TJNRmSBPDOq-!Adkk4rk=`e4 z2D6vRij?V^>1~8ixLm6n^)q(Lr`Uz{<$aZ1^fHysyfyA&Url6Gtt3r34Uf=knBo1# zF|tUcE|w&(5c0cqx;0J(jWiMZ?tXAc{2Omyc(8sLoLP7|+-sXnjR8L;< zSu&BY!4UP%!ik!6*Pubm2uRwKp~AzMr{!^E&ty`3k-deRPVTfv;j?HGa-RzciFQGS zeqd=kK=~Q9%&t+>a0&K&PuiN{RKoz%5A#IjnZ@s3Qbkwp0ypFu2QxrBAfXt>gj|1T zzz&G6-H%%C2tqEdL>e`HmtImYkCSt}isn{tgYNw!C|8rkrmOmS9ci3?+D3v*Fhh6h zO=xMGW^G3T>RQ=*PlkTiE^Q-S8%I#>_U?Pry>IZ|iM4ZX6w`Fd(Jrb)80>G7f>fm; z8&(a`A3uv58Fn>lAVZsfi9x`yLD9-1;h>6@rs~!#nn2er zbe}}|{z*vIYNUEYv<`N}t+<2G31&IC3v3Ac|+NI<=>KM%w zM7OA4o>!E*@i28n*Tj)}E|+x9EpUC?a4_L}s!q!rT4h~oG-IgDN!+LN^0*4efEorz zXcwj}kk5^bfSX+n-=Q$Elkvn*PS*{0l^eKcB|glqFM1((VdBbFmb|r2_^hww)OtL%Q}{i)VDl17A(NzjGh8F-AUm&Hy_vXdcrhBlJq_DlP4FWN$msGjL_C*BQ8hD+5!Gj*56spO}*MHY)d% z4xnq-jzsOBg7U9fwdm*RI}O)U)%5#18%FlmOD5Mu@d7`4sg*gox5|3F-WVfx$M;6= zvCHAA^+;*Zj9shFx;ul%iG;(Mgnyo8l#d3XZ&$-%aX36J|Dy|ycTJ`EfVMUrQMZwD zzOrUBwWV{ObpZjLQawsBA>)c2U4CzNV}>HCw2xNQFTP;#wVtoi(~a$0H7gY;x-o4N zW_W_Qs|nFW-E+8sV^Gn7YNhI`=I?;7>OY2mJCF7Ne~ZrydtWYaa0vPWPw$OY7?Ht5 zE|=5{hf~uV1|#dDqp*TeGCNwx<)Q?>r{{rm>uUFB(=n+qxa%aKR84I65JJ;&CL~b@ zlZu+EnS~?ql4oYUAEJlg_fXHYIO)ALFqs~cWYF^NegQdp+1sYe3lBi~Yap|#-H>#x zeQW=VR~7ctnOG2hv}G82V_};+ePbt9KU5zBw_8qPm4Jcdf0|b71akR?O821QGI|WD zm98C$CFi;na%?p*$k2Y3=MjzTwI<$Th{sD}5EN3rRihvEPzYqndyTEEf7myralL%# znD#a#?j7NC#jLC03po6PxpU~{*P_^G$mNBR^w9c4A}qDUs69Qo(TRP4`I3>*DI}X9 z8V`4Ugi#c+05Hx67K4gIAO3v7J}+^yi!bB&U@LI#f-vNjqIJ08ubb|_ifpq{^Bx=WBa zyzP&ZL(&p-e5c`Z@mwlPSci3Wa-C(<6LL1cr!>pp1g1?~t1OOB5$fua!8=XGz#9F^ zpk*o^H<@fCmb{A@flXL_tJO{4(zg}@+H|l&`nUHY!qWvQ#%V^jx;Y#T6+#6IV`w7w z*Gv_3EOSQ&7@u_%C?q3c-(zrR24NUdm{sRs9J>3-HYF=SpSD3g8{I!sa9fZD4w*u)bywNer>85Nr8^-C|MB`|Urm7m` zv96=b>LDF2benES6`YU zL9$Us%+r~BcM<%=<;G-d*T=DqExF6O?ljSb`-zE7x^mJO)ucL|W(-A(GQ7I?fN!vz z-ALgDgwbApvX{qN1Dith>5=RagpGR#c)Y6R-ASeNP`pNT3T~q}++N(&B0<*ugtH+W z2It|1D-LMF-v@~!3Ya?WjENNw6N8bL`w5*oxrYc}7>kZSe-!W#xXn3GuhogR=4Esw zoxcDLn-N+Tnms9sdGyCKzF~S}>NO`jwyI8z=Kc~sq~^_2VOd_oE~R!5ju8d+!eF3i zKwsI?eL*OF2d9_2=ijZN?g)7MGZ%Xnx@BcKd>6u@=YE2RAnbi{79DDzv~;~+a&R@J zGjL40VT1UzOTXHn(m3qiU!}@=SgU=|!x;VoA1Dk-n>wgAT)2ANPx#Rgsl zyZ3v{*pfR67|j^QVUHJSbqSZI=L<{MPog_Z>Y)(?-TjKmDh zce9|S6W9;%!1MwK*|n3N?}>=^DOvNE^zhGYyuk%5D zBI{eOa}7DTo-VC|S$*`lu?Wid2YF-2?bei+DI$RR$Ii}W$CjN4q9twEy3mM*9xy`) zrVLsP`2SxcR<25gOsT~q%g#q+x>`DeIF`5Vx)v2^iIzSpbxpQPf?j4P)zF}KTIaC} zgqc5T1(4J}6FS@og;>=9djdzF{H|=5ea#v0 zA_I9^Yq+9YiZ|`OR3=&u(B9io{6I1KI}z<0?>H6jI!UQ}*3g%xQ%$;K_Ph?R%3w|X z%3}}s5oam<-@fw@4p0Ak8U+6O$NT;M$2;HddA$?AxImm9 z3(D%RIBy&pE(HeG82pd&m;ZmnH7W!?`k#c*J|G6lAwzgw;1>xc$ThjNL^NH{2mx?q z3m;%#8)N3}ah1A##QEIWfYj(88cl&>UJ`m*H;<%uQE8ffA>@c}*n*D3{ zpSX)q>Q00Mbw};Fddx(gsV6nVM=wbefk(75x`5!C5V(ivA1fzyJ`W!6H!)>vJCF&? zAkZ`cHW6Im~6vhyqKU-?0?(|r5aWMLeBF6q?X+@Hz?mQWdr0ydvt1DtATSFTl z0yFP|H!%Sb1}Anp4Ht#^j_n16H%(8X->H|T1K&hVP;pP8q@B`TQ8AgAE<$@s7fu?e zm9RI>C)>gy@pLqkaoYPI!`JRrfzAJs4t>9ofbk^tHv}Gbo{cRj8c5KVC)vNIh5{M= zjRwIg+6;VwZcG{BPx4;JLH~UJx9opupzdD?Aysr-+m)oGEn@c_QuvU%fh3PZkri|@ zI5rC4w-pfOmjp#wK5k6Wlgu4;2F>d>E-QnWBKSqvxJM@~FQ>=(Wdb0KSOicB&1$k^ zkj7evbL}_l+*$vQ20&>7p!eQE#QRSm?p{HNhpS3VkBc@XuWyABUQc4~&cPn&k&mk< z6QA7DG=%=C75J2!4bkD?faYT3d*nTy{QW!O*c7^HP(n?MpNQr<37xD{=(y-{Bznh# zXSf4mX=UfWW*OWZ#yS~W>16tWbj<`v@-DYq3BFj;(m2KfhSCF&9o*j_W1V|14+A4* z<$dZ*V5T<`igvJdmKtLOg?b7z2E_9^hDo~dI|jW=+NRQ^>I|SV94(*sqiSfn1djrK z9@nXPO7ZSS!6TJP_!o}st)&fpN2kNYF0L>o&+S)8I9+Pb@14>z*>}*8b$W<@sXk5+ zd0Z|hT4Kjg!?0lZ!O}z2JXC~@Uzn|q4%?3}nt~L?i>5jizq}`mNk9vuCrFZ7iUD3% zdMXkl+Fu%-Lh5JCxwm628k5jqe1Ql>2Y2(|LZfR~ z^iMYycN1y!=*>ONx}od?V`#}8uW!1$`htb87_18>*S+WDJ=`p1vnwSSkCY7TjuSe# zd_7}4<>P!@G`i$Xy1~#o$sXjPxrpLy{5WCRfV>*hOHii7g%Rsz?)idF?5?1I3`@h- zu7ZQ_wr`>VVRZl~ecl<@>#TOmi1&u~CaZ)RLEIAdyj?6&1!%?bddCGRJc^FGU-^-U zMY@+zk%6Ok?3X1pm&6fV3~0)`??gu5>htmwp20*lWV4|Ix&_8XY>`#JjRIe;_b7(qR$*18c=hv2E8w2Wyub5+N_y@| zfwW=rG$lYK)J_J{j`|h!O+NgaF|d4`*L&Xarq556o?Bd#uX~*YGZqsR%B_& z-P@@mQX~1j%qod!3VZ%t>Yi9OFQ*FAaXYmJ*>~_Z11JWtF3gr%8FmQOBIb{U*M;`|=gzEVTTG#`{i zrIemAJKLsf?rdG)D?tY2wN9L8*9i*u4{tC2Ch4W$0U~DQ?g$m8opY0;-6X!}dWd$P z$xhU}a1zl_i%X2Tmc8!>S^Zh=hE_MgNXu!|=;_1GMq7j9;mfh1VfPUxmW)|EapIhW z%uWOJq$M$hk@JisP{LhBp^=G^c>x`8hzY9jdTJ-0I#?seOSe5rZxU4zs&dF4(xE|| zXeo9VN)nC6;wbJK#~2atXv^9&s_Q4h1Xh|k#6)@8b#D0>Ar{exv!!T=`wLvgwFW#k z04|04!{ly0u|8QYKoSU*|Nj}L)pe2j7B z!&gr3NP?+E9jj|3^()~oqm&5nfHC3!Wd}RKpDYt^On5(asfhOrIigqagcT}571=%j z8qyCC3nca9CIh=e?D1fFCeG3{L*Q@5TtTm3Gm><&v&^4@oTf|=RH~*B*37aukvxvP z^X!eWmj~ua0Yo_&zi?-dKxk`=UHwg z&G5MQyQN+JK0d|cvPpF2&A_AKW7(W?9-@Jj43n0r@GHgwCVLcP&XcC= z(CCksqDS7EWMT9}K9V>DM%4HojL@$_*B=mg-dl>M$`VZyF=nBBtKO>HHWx9`%%DM< zQ3jjXu|lY3k7Xg)do%t=z$>QGBB-C1iM*SYs<>!O*D6 zN#Ig8PUyQoR@Ru)Jl6xhV%3e?ByqzwBAIh(eKpyMxpbFE8(8m@n{W@fF!w` z?ogRKmYmJ^B0UK20!gZ^F<8}=aYED~2WeZn;AUnWT*yr&8mp2^1= zu7^w+Fj~_^ZxULVy+S3eZ^3UeamCkHWNmsRwHrF&{tDJ*Z>Hl0QuG%|#a}B)SxNxG z8!wNejDHXk&vYl+W%WgCl^(P&X)BVeVC;4U5WO49C1SBW01l9{1HJg4KoE`U8p=m# zoBs=b`|&YkKVTpTUFwB70WlR2Rb7Sm8FjH{r{;Mr$;nEho0hZ{a|z{kC_0 z-fGMa@+EHu07JpjlFv(pmPk8)P zDLt3GNmVxBrkuVcg8-VfWB{5Mo4v<}r|2si%Jw#j#mju`2KVFd@^+ z&XYj7y0ct9R1NS^lAm?<4~nH^LfJcBxp@M!w+|R&kyc&vn8O=mg(Yt>EDW8R3qFrp zm@?=aI1&ZvQZN(jq!q%bOK<;6xaK%KLXOul0A@~RR@c=61uM%=S?*IOcULM@tAFj$JvKC`X*eLL5cXr43?m-LU z(7bFgSy`Q8cr_mE?d4Sz?z}o>-Qs8KcXjY5yBh_g!3VL!={oVA*I8A1dXhI7=jm2+ zU-;E>i_t1cd$`x35gFQdgG;=!H=QhI?;QkWP_zvFSOec$GUJ==gxuE?%$@F9Q^!@k1(5`XC3^ea8>BLV@(3l<24 z2acJDL~L|G*?Ll-mftak>ICvT)=mv$33wvBjo6YjlRA33R;bWIrdsF^tpabe8wDQ( z_BPFfS~27$=EO{($!3?Mpu}!DFf@+GY@U`nq2IsLfUsfa>%|q`Z1h3Lc=V=JmdtrAu#RK!ZE>6I~fP}rjhwG5jses&am-2 zQ??3Es6T3+VnSTz-8r>y7sJ|Hd8Q;_tHxErh{C~8G?{i+SAY{}BWQ}H^xh)t)Vn(d z2RUZg-Iao_ixpX*0(Wwstby3gz`nhwcBJ1xbC5m;R-S+zC)Ax^lcy#L!-_ygmAfsO z+9*Y+zHavoZpE*P2}euuW$kh>NY_(z9g1#7&w^7ZJ1TTfY5o)@;xa86h&KpsN!S0$`AFyo5LzZH4SEc}t{0BxQOzcp?O?u>>(}en%d1a&z5Nx@yxs@j7Y)KnjTj z6pJT|=$b`egVCv+7arCc_Xs^4 zyCz8Nh)~1h*G(-1Jw#5<>%BwQahz%o#>_i9hLnsNJd^cc9{-VkeF)QM#JSup=c5qH z6oE>i(!BJ_iw+)N3ra<0Mz`o|0IO2o-zgE9nLgPi_AefPfMi&7k5Yoj;n9Ks{R|H} zqZO?z9%U5Mbfr)~h$)%7o)SBPVV-Oe9=zyJl7Mtv2FU4goqFe;*i{gDT7T!%TOp?< zRn(I5^g}>*LhJ}Z(WPXomXB3};EU)9Ay##?sLocdPe1v-BeWIC`$W{k<<79v@!)0K z&@2EUwHRj1QnCdMOrty+P@x#3!q8-Tx}lzM$ycyp!Ffvdn)l|{zk#|e!WbAg5mVk4 zf1G?$$6V)Pyw)L?QD)P-k1-HVfBPgW2!o5vflA<+PY2!e@N&D%tdq*1A#w9=Bzz~l zU|HIgSQbV_;vgfy#89&ZwK$=HX4 zy7O?|Cnp!ws%oZgTy;y$!P@COO-j)#@Ux*S8N{+Ud<{JAJc#_AA_s!Fa!rO%F2>MY zS(orjyd`F9Xh!zd3f8LLn0M>qs^w^_WK~Tp;4(O>M7(9?>rGmJ4L6(A+RTuXwk2^p zlWPS!PjD^r3o6JE^%Wb0)0MlcBp7@TB2%L%oY^_GoppoqA}Dyk%llyi@3$I=p<${6 zcw|RXoL~zy#_Fpj?orb7-My`fu^2!E!X0qg z^4A)0-1FGG7tN|MfpIm7taMmcRr-QfkT#)yFmqw2(bBS_HJ%hX5y##82z&Cf#gZ|t zn*c3DgcPt!XWL)V-7ko{)Q!}V#SmM#!Vp!<_2upxE{%U{rEsUx#?n2q6?;R9WPZ%B zO?(fj7bF5yT3zh%F$auP{2wOc1ckx6WHRp%5^P6XS!lxz9Ai*vt(6Zk`zmuovw&_3 zhor~wkVKqCn++0WfVPTZnDX`hu*s{?z!NTGTmBLkY%gq3x12t-JsLr`)*mf(Q93F| zu9Tl~RO+Ax%7x$gxuYa)F#1+@mw5tG5h#22k9){;U{|>LU?q@Qw{<%KUmAy&hr6$f z@SSGt6dj=w1Q+k>oL zH|Xz)G^5rV)6DA#b)1sWoQN4qL^e`%{&B32h*qTRC1n{sC4m#-i%~={)ZSk!MUu8M zBTD=7wVoV~F&aeQm}Xu_NPw;gm?&JXKO*waLyHZk>orDBTMVkEef@)iGVwjtF(RL_1v&2_c`~GoD?A zxJf|kp=x-V0sf+i4i3>$DX&rn#lrA}%elePph?QkW$NYl#VDY%?0bl)%xh1e%McWi zbK=gzc|ftfI9wu(u?UZb=K{G2j9W!U%j94nQzYX0HXh2pKU_{HK4#s;6#zlsxCy9M z5p^}&o&f$Pn!VlwE`6NfS#tAl@y$k3xOQ!2D&_vPxO4&j3_=Ck& zjq8=jm#(!Mdi_qbwLTT=vCp;ciio-;MfzK2wPZ|QHpb$jToae_Y2dOo=$KeknmjB@ zr?RAshm75l`VusuR$T!SEZPc*SOo6}k%_HW=>zI5p02V(&?l*P)=hLR?g}@7`v((| zvl0S!D(<=q#XxB&4^6stFwqsQglYp_PQhcMCF|4}w2U!mIoq`2Zt$+0q(|?v3FYuJ zp1m{k0mOSBcm>j~BQ-1W<#NfrxvrNOK;AvgGrm0U+R1)r-eZ(WdFIB6^dt~96iwzl zcNx$HaWaCv7lqfLJF(sZ5(2)hVPz-nSg#`7fq04zxdOV>6$1M*uzVGiEUU)2%dm^s zbOuJEzG6LW3!ldQEz8 zbAs+JQIlFJJIB(Wx#yzy^NR557HG=Si!3U;7Bx}{AX{j#wl3L9lU1^`@*!6P5hbX! z#+kJ(-Ccl$u8t?(4Sp68&8wtgMk(d*xWOfFF=0YmN#6W12RC?Y72N{nmBNn&Z#+AZ ziBpb6Zn^;BE_M?ja5NA-AU0y92`1U7bj;*YGIkn7o|X>n28{}J1IWy=@N#ebMhNaL zg$8tQi8ThE{GPkCASU4y4r?!8JFFy5X7d7u#wGBsns_1jN@<$?-!XbB;bKF9x?*{N=#T&bT)`E(Ig`QHn?p~H% z(>8@ZEzTZ|pBP7m710jYLmGjD+-+XMN6tH@}q(8Y?NL=D7#(cTOm?LNYL zE6PX^V3xmmD1wE_f_pKQ^X{}5w3BPXQ1y5q5_B{!H8;h(FE9-o5SZFMvJ)VkVCUUE zQjbp%-RrCjlpsp4la;ge&|XxfbjGbKeRsdfx>1iGHAB<>V!2=3iROW&iTag#jO*pj z$t6$t-!t?*Ok+sNnBbjF6GbQ3+KO?zV^+$=b_u&#^QG%^2rDec8vg?9?Hwp)rA|-M zry*p_xtfi}U@honSM=v~R^X-hgLj_-Xm70aCrY%t2+f9P#lOjI z^k51Z5T2EKJNZ7r2Bq#wGo|YF+kwy;qiDQZv|wEr2biw6ly-}s}a&-O9 z#MOJ|R*{@2KK< z<>f69QFkZfc?urgI-$M9%9L3%o89}k1^3x2Q849KmQuQ`EgVZDT7AE+I0H-9m2&(v zWp6ey@)gD&mZ!igtXKz$(_Mm6E6ARC%2=81Qp9Y-+4bqACl67*#%hVJa+;u>a#@KTEfcj;0 zqXt|4HZb>tNa~auIi|lus)SYVCQyxg3bQ5JtDhVqBO4PsfHBM8vB|Em=$$d?v!!sB zQKtGP>o@^m>tS`^`LU9S9G#ywWoBUVz>+r03HjU`~`)2RmnkmY@=vDnf%Y=085 zZ#9oolCN}&;rTf=+1W5wGsC58Ke!WT0jK6cqakUinKD{rXX`zEnF4Ts`?OZ1gsl2v zh=7zXp|(b&+us`Pk-_!XNiz^BZ0leVoO6vuzkvliy2I+JHF}}lzULt~A;-!4hPTV{ z87QajKEb;*XkrOfFpeuMK)QH}H6vY3u>ll8%IftSl(ubeRIjYnrvxFVmFyT-yrpXj$)^RJQL`AH<^l^4 zLIswovR4k`K-3p}9UpPo1egL0xj2A(jvjyNaXahQP~KfAehX9kl+XK{Jm(&89*mZ} zYK`Z1B6xeJpxL49WaZpU(FY(M_IB-i3RrZJ*QHg}6vt3#F|rKLdq$Vq6Q7peuqflt zBR_Nw)|bN7WIQSFV!3gzv$AsceDqGjIM@InGOU4=Ov5D_GT(<{)xP*-Z&E#j$zN>p zu+N2*(m1^@wTg?VK=xZZtb|)RRO`W1r>_sN<)H=KOYwiM5rPO%qi z?^hgjYLs(Nx54@mqp?QFF~g=A8`4^DN7r4n%O@2RyLJM@9?7uxfS{zv!|Y&t$&`!Y zB-&hQ zg38?X5mQ$l7#$a$CrbB=4_lJhg_K?IW^f#**+ijkXUcC_oU)y08(z{nQ1D)76&JO} z)ox{H;Q_8NFxi`=qa*z$4^6SanpIBBh3!{QT`D+Qr`yWsZ*B|PDi9hka-Hr;N zlFG@*?XWDfZR}3D6Qsl5E~Nm;;l7i~gt)=ucH1|?7%+?(fa(n0o>1eM<$fiYiGBS+$Xipw)^~+O zZwKsjdrC+HLa$ay?(-5)QtkzW&X|<|vNx(oKf?nMWkd9OYvJnW6kPO}hMUI!o?EVR zXnvEnPNDnC5-!1rsO}Ea11;}I8mlWm@ORl8nNvG)>5%Y(AZxk;p0>nIgcpS``$E3d zk^q(VBc{%)1g#!uv2pM%T-UTsi)anrT6N{EYG}uFFIZR$X;ow_Ya{}nrqG{>I`f8pQ%xQe!8pI(o!|O=D2)IF z(v{Z=0#EZA{?XrTajAPX!0Ub(+&9@kT0!3g`Y9|LLI3Tw;D z$8w3b$@(Vk^<>yesd_{E1{E1sFJf342hmaW%J8ejiYbp}-m~!erUUSXB$fRWi8*}DP5$C7rGJe#mn@g?qf z2;go8C(VOBRzgbX;!4v;_}u(0|HpXYpyVCgdLryhmZ@{x9>f`6&K_vedWze`DMWU+ z3Rw`Lev1vNuL%AOG&=9GQp)6dt=u&bEyy|>AObusw}MD9`GfppICqEQV*m+IX8dB0 z6YS`hge+TvGftwG!buWQleXNPG6wWCj=dyamM`@#=j&M2iY8hf{YD>~2%ocUjj67f zP#=RXwFlU0wG53Anib4b+7_wxB5<-al_oq(#$?w_{N)ik$N05|nQ8Bg8jfe4{c@o@ zNkG?0?yZASa<&NWF!SCYYid8uKoIwW$*Bt_B@^tX!hkd+AYR3SE}=NrhM?v0PYjK& zKKFmi+2|^!x`-hW+Uu;`4+vq;WE||zV9_4HS3AC7rPFS0i)77aZ3Z5Zrvss2%Ye(ghjDqEoZ}MW<1`@{wl<>e zxqOgvxCQmossKvR8XizIV5jGgz2b1T8^$YO#IiMYyR(Ec7Qn%X)1x0EXilzgT~}fX zRCdZ;29Bme7tnxKkd7+8Q4#b2hHD#Ej2oQ(lk8vjGrWq4-`(I*Fm|uAlE;A(Y{4)t z32+!346}-#>CzQffS8Pu=bz3_L82 zlJF;&P?pwAde81KPDpXVMOXdHETQ=0B|Qp@c2i-up`dhGND&Z{*8iLa4_n8g34l=A z%(x|buQ~xrGN{zNhj;Fi^uTiU2F&DL5TOPICO-!)#g7-CxP)C|C&22G0f%iXmXPRl zTj`-p6w-Vr;A&VFYu7mM_R~8i&x1KBAl+5*UR@PSwZDTBmB1A+OS&g~B44AcHYHd4 z7-kd@(X44{RQuWQ<--3vIb3_eD(9szSP zd?lGR;FO?yVBR$0G3mAm+H@5?!|o%*TB)mQ3TZKpZr4kM6cK6Lz$D0pf6;s_CSi1p7g3y@KW~(^3~>_fvX{LtlR|?G!-BO2qm~ud2fJa*CF(6v`tkP`(SR^ktim)Z+t|LUczqB3kD4; zrF1ObIe9i*v%#6JG%oOubw3VRWx`DJ^0gjhEO4A|Cj?B0GhS!d$s!Uq8B8=;j6K@a zxjS5c0mDTN&a`}6tn5oT+E}b4zOW!hv~=FIv|S;TyDv-7%|hETOXSYOA?C`ZnF(_Y zLLHR7OW3e|z{vXo9bQf#9cLT`yI^8I4@oE1yqYPp^-iYu+~&jtPPS>WJt7YZoCj}! zifC|ytCz_tpBu#bbq54q?>531iCG0`OpB*lMivXDhTr|XLxK>uLAaed_ZA|~ykY*` z=?#MQRHui2a7o}Ldl2m@J5g`);XBm0E8ni>BwoOuFm>%!HvQ%$ZQUX5ZU9e^5Em#3 zY4*JN8Q-yj(|Z&En0v1U>|A z3@Ov-3H!@sobkW_Fho0#USK`Ol!`Hcn&%}bz&JF@c8^4OR$p=Kw~>{VO?6?C2RG0ot5^&vrJl!x;BZu?3r_S zDJ3FxC))G#KB)*%yTI)vdf0q-6+*pakoY=OIUlF&y>oC=7t?J|XhOc~z0Qh31Q1U3spUT)t?DljbFPtFs>i08Rq zAS(Mq&ps3{b(Zd-cZH-mTnctl?U(3D5I z7F*Ta?HDyIT9&2&+log`SUb8$bhaGIjp}!L65#{CG>mq2eAiVb!iTm&fP3DjGjUb& z`fBnKQyD_GmzB?muOcaKRc5~xh*;csAAa?fMCbm&&yXL!J{#CN_hno3xG zzk%sZR3UBH*^d_|+B5WT(vUn<9h?y`Jr5}bW+n`&@&!yml9F@4nVjz)lPiv#FC;(1 z;0*0*H&r^Q3V@oz3x1}--UxvcaMT|)Wp7QS3n%xydSWx;mf`&w0(P}qGrPkfW_P2w zVH+YP1>q9&Mpv?_l9HEslb|lCW#F+X>X&z_DW;a#=vX?DxRZC9ZqQvMR#&9vPx7`h zxtdfe=#uoX_hL@W2^~u`p@iSFIYf)Xp1@^j3qUPdm%@r!8QNDHHN9hD=$)qTarxa# z7>`*sr28Eg7*vPRQ?{3$d?$9>NkmrfNqQoYFCd!PN*7UdM9!sfVnvF5NN3V0MhL6U zH4+WBgO}W^kBEwAqNxJq=yN&_V*{mup|!4Zr|&|yxKIp4i3Lze)Y#oq(wVf?@#JvX z{A6g8sdBL6f4=LxzgE`8s#_{ii%&*od`9McsjIPc80l)AbBdxN5_(Yr4eF~p>kP$whLawi9lgqptzCcXj$Bu zHVsDP7zpAImkDIl-E`s? z?33>)8!}Wl5l6rg#-pha2(lh!MgfrqZuz>@81evF{FKhaZFUULQPq7Zd-3a*+Pk7M zx~J!LGHBW|FU7!{@6mWmw1xsxmq82E8>QdO@_C`kxkXSdgNvE8z*Xs{tw< zP(tnu8?|H&q-i4B-i%QtDUII3Y8wtsHX>8#8q7;&3VtA9zzJc4nvcieLFko&nkMc?S*U2H&c*C2CmzuM57OvNaH#%GNEfJKAl9n z*u4bbGna`wFHWSPTOl|v6;WgAP@+P9xc-F9c5$o$ok^43GDQ6X=`(LQc%H7oh-OgeDLy6kT)ScyK=Lo?E*;aAgum zD$CUY87QKoh?c!|uXEENT3NhnC-Ss%ERtYRt#b;VXk&MUPr?vRW}Y4DENQ2CP(GF- zMtI$9uOp150$wNGC2lgr3f$h{LFRTVDh(?=i78{BFwJXIPV^I+#<;#s{;QV?b}dEL zMh&Hdp)3oHCoD&!(t6;Y?#r|m9aHhrdoz}T!OWG=c;i_cX#|M}g2>URJ&Z{mKU}6y zewRxa<*eK}_(|kt=hAoGuM>a%JlP%KOWlU2kS>#J=|m;&ko5kZOZYzPkE$ekwu5Qn*-|iRO#cmS&p)^ALK35NBVJ{<$H)E&;#h8z!qY;q3=nC~C zJDaco#i;$=awpWL3{avcY1=VhT6kFKGZxO}8Dy!HS^#jVHArM!tZgZGoL(eL6S>i~ zifR4^R`@CXcG_BpY$p6HpMDd`+`vKfzo>A^?1wMo?uUy>M=giD_6^kec14UwSnc|? zQKchWv)-*s@y522FK8LoR1f+bQA1yRk@&91-QTWOmy4zdN1U-SixK*xUJ4MEe zu~zN|X`f1j;*@k*{8$z-F2nMkrD=yWIyW9Fl_Kw&Lh}kSk$&;v93>GO>T0K&M3ubW z?cU}u>}aLeS?Ldl+b)SZ9%Kx01BS<=+(#G^4?;xtx_{G5i|?{GXms4Y0zAR4c_R|` zHHC;}-uTUfx7$#<^v#n-8tgphBVvi(-UpsOPFG9PhpeURID_ijyL_@VUkVo^D}fUG z^CgC3KAzP<%kV*oJ$%Wmn?QPYvr5d2=z=-5J%KsgE#N^-P(Ye6kf|S*ygz-PbbKs% zi*yma*IJd(HGkIIFUwP6JR1gkOZd>YhHOAn0pVSYoQe(7`>^DCt;)GK^#TkKQWa(> zpwg9Youts|JSa#|z9t;sX*GT|M*$bSx^|$Of<5t(}wECzeK7^!bm`NYwFOMhH15p!J#O{1s&)p;p z#+AKCEdx_%70r(Lwo>*2&9zcMT`57QZ{_lDNnE7mt?WrleRbrZ`sbuu&Zf!Y7#ygZ z6#Tr`Sq8t$AFS*w u8w4LMoik*A!kj3+NOyq)Lhr~Vm45*$Gq5l+{+;9i0000Y9q literal 0 HcmV?d00001 From d04ca7ec937a749bd94c6657a4f4bb17c6804f57 Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:31:58 +0900 Subject: [PATCH 005/141] Fix broken image in 'Old IT is dead' article (#42030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - The hero image for the article ["Old IT is dead β€” GitOps & AI are burying it"](https://fleetdm.com/articles/old-it-is-dead) is broken (404) because the image file `old-IT-is-dead-736x414@2x.png` was placed in `website/assets/images/` instead of `website/assets/images/articles/`. - Both the markdown body and the `articleImageUrl` frontmatter reference `../website/assets/images/articles/old-IT-is-dead-736x414@2x.png`, which resolves to `/images/articles/old-IT-is-dead-736x414@2x.png` on the live site β€” a path that currently returns 404. - This PR moves the image file to the correct `website/assets/images/articles/` directory to match the referenced path. ### Diagnosis | URL | Status | |-----|--------| | `https://fleetdm.com/images/articles/old-IT-is-dead-736x414@2x.png` | ❌ 404 (expected location) | | `https://fleetdm.com/images/old-IT-is-dead-736x414@2x.png` | βœ… 200 (actual location) | Built for [Michael Thomas](https://fleetdm.slack.com/archives/D0AL6RD36GL/p1773883444151599) by [Kilo for Slack](https://kilo.ai/features/slack-integration) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../{ => articles}/old-IT-is-dead-736x414@2x.png | Bin 1 file changed, 0 insertions(+), 0 deletions(-) rename website/assets/images/{ => articles}/old-IT-is-dead-736x414@2x.png (100%) diff --git a/website/assets/images/old-IT-is-dead-736x414@2x.png b/website/assets/images/articles/old-IT-is-dead-736x414@2x.png similarity index 100% rename from website/assets/images/old-IT-is-dead-736x414@2x.png rename to website/assets/images/articles/old-IT-is-dead-736x414@2x.png From df526267762c82af4bba33b9fbc73b8250e7541f Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:53:32 -0400 Subject: [PATCH 006/141] Update old-it-is-dead.md (#42029) Fixed bullets & need to look at picture at the top cc @mike-j-thomas --- articles/old-it-is-dead.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/articles/old-it-is-dead.md b/articles/old-it-is-dead.md index b5c5442a676..694c581f653 100644 --- a/articles/old-it-is-dead.md +++ b/articles/old-it-is-dead.md @@ -1,4 +1,4 @@ -# Old IT is dead β€” GitOps & AI are burying it +# Old IT is dead - GitOps & AI are burying it ![old-IT-is-dead](../website/assets/images/articles/old-IT-is-dead-736x414@2x.png) @@ -113,10 +113,9 @@ The engineering work that requires human creativity, judgment, relationship, and - Understanding your organization's actual risk tolerance - Navigating a complex stakeholder conversation about a security tradeoff - Knowing that the policy that's technically correct will cause a rebellion in the engineering org if you ship it on a Friday +- Building the trust that makes your IT and security programs actually work -Building the trust that makes your IT and security programs actually work. AI cannot do any of that. - -You can. +AI cannot do any of that. You can. What AI can do is handle the eighty percent of your backlog that is well-defined, documented, and implementable, so you can spend your time and energy on the twenty percent that actually requires you. From 5031eb69acb2ebfe227b6ebe2302ea15a3027169 Mon Sep 17 00:00:00 2001 From: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:54:16 +0900 Subject: [PATCH 007/141] Homepage update (#41938) Updated the homepage message to align with Fleet's positioning narrative. --- .../images/see-reality-clearly-528x377@2x.png | Bin 0 -> 40222 bytes website/views/pages/homepage.ejs | 94 +++++++++++------- 2 files changed, 57 insertions(+), 37 deletions(-) create mode 100644 website/assets/images/see-reality-clearly-528x377@2x.png diff --git a/website/assets/images/see-reality-clearly-528x377@2x.png b/website/assets/images/see-reality-clearly-528x377@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..037f09f14864d6c9b05da01b9cbece6f24c0a2cd GIT binary patch literal 40222 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

+LKU}IJlGIg$X2Zg^u=)B) zS-OVqWEnGRa18-NFNdIaDKA7mA!Wav4-Ub(R!gaoz~?7H(%g_EC7FheEJ5N-HLyOO zfII|a}H9%Ku^wj^K`-Cz-SJ@l^_gW;Z^z16#cUd%Wap z!pwm6wyGf&Nw6kBHe#}^6}#rDt?Ub*cac)P{8k6Wf5Q$@TE!SW6fT51U|nRIZfyy0 zH@NcWO!AV%%_+gKJk=NQ{a(JYpEExrV)U#5yPrMRuuRxIeK?;V4zeb?l$Y1?j-O_D zz$1nw<%%8IC-^1b@Y&8OdaIDzTUZdnp)3`=(Bh^x_caN*+G^_7Glxw`*L^VevlEV~ zN~>Dje@1FauQfrGDPRcaStI)ypVP(K$eHHPKHYc}^MD$>*niFX1J>NC7!*Ee*MkJk%N$A1k({zpLWV=RTI!~^+n`&zp0^h@WNP5I7$JuMvvAG&(B(6q zTZBK>7kUM76X+c-1_7D;H&&%OP_K72-<;E}3kt@c|1$X^_yvkTs08ryz8v5?{mgu> z?bj~FKcZaXIoq&U58*geoF}Yom&c@@<)lEY;|prJ*-*``MBz4Mq0pT{>qC)^1{ZdW z2KKdnMl}>)=R=x^YpwjkzPVWf14N&Lt*x<0D(g0+?%xCMCn2=@5~Vs44^3r3i8()R zuMEF8{AW0(6CU~jCcj>D9&~C^FS!B2>JbZd$iXom^Krah0@1dDv92{gcqo(#oDgZZ zC1fBLxh_%_4(T%I2#@*lyiXzIH`ieHH4z6vvxLumFsMWRXs{ieCMAnUv0#F#?XeF_ zN`r{3#uWw7mNQ#X!lnjPot&mEF7RTo!aR1hLZTSbUy{6(;EN?AA_eK~$HH{ecjQQv zp33!aC4l8WLuyp>e>SK3M1_SQ!al6`Yb~D=>Ekn=0ymy1- z9gUx*Cc=p$Kk#Z5_`LE{O8XSz;Fri+GE!7XkltuU75N9xbbEcaT_A|K-(b7$KfIbT zeVcSrm+rO<_1?x>{{&s*1HBIFt{&k5G1k9H(q#&Sj(W8M(et&Xchs1<>+PuJikb4d zfJa4*p-<0{!Wm!>o$0{WIg+H)t=~8k%z#L-i_bd(L!)%~Vg$w*j6Q)AbIa>Md*~3mMQAjc}mh43}$Y7~`rQ0Ag{nU2EuMZ$x@)yWV6Nr|E z(&(hrtbbL!4b7Ei`92npYLFRyqGGWwz*JbC{AYe4Yb?O{iYTtjEjm^+jff*7fFaS7 z4S&{xoO3FGdb>a~=QkI<7D;4E(Y|M%d=GRxT5D)=X(UqTJ@zLO5cxAe(Bb63%u(-` zpxG-dQnlIZR`BU)Ha<^Vfo1C!==5uOgd4RIGK6~dC^)aWP((3+x^rZdwG%LOqw z^~5~mbCqBvg))s+p5KA3Ups&PFQyFAcj^RY_owA-hpD$@iZ%g12i}<_od1@Om~2g; zl0M#2;lNP)bnlXpn!Km%FtTgP71t250XAPgRZ$`j_fiUtfSn09uG827M|y!(dN}*> zxNZojH@I&dYuiyj-R47<40;p5@gZPD&4lsIS0)v*AZ~ljXK7MVZ}_%{bg7ytuH1qW z{9bMyooKWk)m+nt)Tu+Cnk8^o^mO-PDD$hi9x2P>9|%G6 zIHOIDiH5m{lgZszzd}Gdg923)cLe*}hNLZ=7Qh0N3t!8p0h*qK1L{bw*1Qq2aC@P* z4A$O55$k;YtuGVQ;#w(JMg$^$d1KzlMlGg$O+xhc+q=3P4(QdKCJ6Wo&mKcsQlB+# zNgyX}5evMKTR|l0ccQ$Eo+MPTn8w7HW)&Y3rQDHA@FU{hME$JH8sF>B1=#DJd4@A-B1Nl-YRuE|Z{yuDx?(54ech2VUp1u6_jM#W7|-gGxz#H}V!MA(!hh5dSpIMfNIt^wTrZIXVWB z{5Qk1lNUc{c@xki;b?twHb+6aHW`JonVDRJM%7k>4}_({l#tS*eX~<_b4rGx0kQ{~ zbLNkzJgQTLKl1|^4{cxRP?5hrG9+Zb3@40Z9-{5jw_O$OIpbZ(L-uBHRIJzMLtk^iCQ|X1fXacM4_xMU#O~ENDRa{|H)Og zhf(C2ZCh9T%^{@RDBi!VK|mO_ySBDNMMu?OfSOB0WSipSgfqQgYYL?5Cp3}5AKC@c z3WM}wTT5StH~@vj!F71r!)Qr65o4LWhGSVDm#0f63kC6V=&-(Sja`yRZ?k++nGf;V z(HX3_Y%ljce&hzq1ms6gNbTOdAo>oSv~rJMg5VXxfWe9OuJ_7;jrQ+JDgOOU z>?rguxGl{sx=5hAPe+Ak&MLtjBKCJr?20Hxc!i)$d6;#t;bbNM;|}a5!d*=BK9&|< zIGsBQkL8ZFhd4^Z?lQ{Kj_oa7v?;(G<`e5;SBIWNHbNW2(sD#c&hh7rp>jLcet*^^ zHon-^TF6qDZQGBey>NxY0DkMERf2hC4e|Cq++_Z}u}!x)3F_!Z0lU(GL(h z`^onkF0MXDK_0LUoLR_0aQp7}Hw17@jfTXZ)BP-6J1l~_>x=Bk#U@@0Dx;q!U?&<` z0L(0J*=;~T^HWj<-y@SUo$>I+d+j{W8S6=@Iu>an+5^>SzzzqLe9wff5>!5)6KJcO z0$i+S+C3x9Ftok^b|vKp)W5mMluH4`%PoMBwaXoi`%8jN|0S`ZL7+}qrDL2WdS3_9 zJFyDx*(Yx~`HB1&Y48fqiO1K?(tg+s)KVynsHx>4f&e%DEb6gFyI(&xXp5wUSd35ep8i)$hrZ}(fO2u{gY)?4*uaWqQKRih8X?`dkK?)adYM2kzBS=tDD}_fA?g4Od{6t(})9cY_eFV+2J*x1M^^xXy;fbV-6h-D175nL>1s|8fG`r&KfA1bGD%}NLnU+ZSW1SXWq`t}9>U^2wRTQB6ru5d~ z;BA2J3syE``_wOtJ-4BWzaKGk0K80mZ)rQjFJWLftWE85J`(;`M64nHYta60*BAXS zo_-$1u3%V1-3+!`LAJ5IsYmu8r2K;zOTdjZ z&%xrSXw#ifPb2<41`W%K1Q$xzuzbiYkjN)r>m-icr(+m-)4x)6{ovV%m`%|SPnni~ zO^mi`)*&Gh=!@q0+T9;ypy9G-=kP+33<$d=Iwe`~^6p95yH=b}`wyQr*k_rnQprB* z%1*O#uUL#Qo%j}i^cfj;wQ|>4dCrz|Rhhvs3SpQ%qVFb~Yqq)G3tf(~^(Xx#~;>e-*CIzu>NQ+ zhTEW7I$%k%kJ5ZaqAr`)L2oSl8ur7ta1y&C=s3ML$~``V_BeY^BSaquZ|`2_o|Q2i zm3$J&XU)@0^0bqOpE@_lc+BF43szyje(C;m8Xzs(y9pN|q{fX%e`R#V>zZylvvT)B zN&VyFCQN7gVdeR6Pu?}{RbssxIs~&pw?tnl z8O;~?Q;-QR2K6!13+4YIOOt6;ohYalOXFOkoheDWfXk8mhCHa<=Hs%%!qYxW0aUlO zEv@TQSFNqv+ZpVwcF#njKD8QUUbfmLGXl0rAGMi{V?8XBGU8`LNlF0EmWcnVl_nKH z-saAs-W$i=G0mAkS}?l|Yrny_#O+dLE?U}Fa#9{QzH6u6dqM&n-kMF(zS%c-<5dGJ z0X4S-Qt_9j^k=?$Z680KJy6_7S>$j7!vhwuAb#KYSWm2 zJ1#uH$GBAZThGU%SvP+2SDMj^Gp#0Ml%mU{An+p`+_fAfvMLeUuG+puXB@qqcl;&n z)1p`K}Q{Cson6`G%L!C6YrTTN`OteWxLh0-OWK}DLf8HH!k`?rec$WidJ z&zIBOfDfY$FKz`1QVGG+q=0@wdU6LFD%NcOXYaH(^e0El5Z#&pqv@UR+^5NL8H8#L zczTE_gcasV!mS@yAc!0?E7l(bm4x+rWd#xmr z9x6oaU-L5KYjN26lzhSv2hDr&>G1axX2pFt>|MjXk!#Jlgb6sX@Z!3cfJGA__4>MX zt21;}2C*y5y~7Z)@lfQ|YIcMDEUQ36=JYBanf3MeCjk@;Z9Bt(5gjXcg#<_51kEMr z@r=!z2ME|RXOA#vy#HjrKJXay#>1ctY@bvh6P7|~F?`nr|GnF1hzy~EEoPNw3hg;! zNRmMu#Rk!YxN?qD9Q87Y$YCE8+a>`3&CsO#RA8Rd{ZWImganaE4tz$`yV(>kbg2EB z&Mp9ZVWUwdI^q~|;GOJKU}zzKxZje!$8Pv>SaOB$)~u*GZp+&`!XMCimXUE>gm=F~TYq2fw${8YQ zCw3bN;CQIR+zWg}K}ybM-Gpi4$K8kQi8s`|>S`&+weh%)gzB|;HaVd`Ugv4AJK>-w zQ_U91fxfiy_BkAdV{-5?7??FV3xxTrj|g>);IO}>;kwS36TviY^X&IiqZpH{PPM{> zQLL=}IJm6c>Z7l5IBMLC@5IAkVw8b4oN$)SK=0Fyf(SluX$fW(<`SdxtJCi zCRcyOh3bxyu%D^mq+EI0fgqzSD9(`puym!s<(!yyZ3l70cen+wy7?B_$o1>rb!`w- zyIe8d`NcEAC`N>{_@QGv`#i3~`*X&~FZ#SG?{JneMGiZ@^lz486pOpa@9wqj2k}9S zpL6+v#D!JMeVdLHT^oFl1VCK7&6na~6_y9$m|3^lfguxUAWxWXH#s2%aj<_VA%ww_ z>rGO{w?q~0ycdL!#oBs2)P`4Q{tUBzR~YhTT)~YfqE7QueZe(Ve&^k}D5klnR|2;! z8b10Y>FdV_A3eoQXFp7?V{`g1e~g)8Hju7`G+}Z^055WTPd5@ z*)o6`jms%JbvqTjlx%+iHxG!?w~Y}ysQ|->nPja3L6|N^#%vmuI59j_?Tg&5Ry$6o zAf4+^X0U6)Fw1wPZa8QpO`%zN19T=&|FjFk&npTVw$?l%8++Zz=lr&6v6V=0`3VkW zw{No@U!59ZNjmG2<*7pn+gu!F!-HTQZ?6QSbqO(N&I7z3_7mGIgK&G}q)u;1{(TvW z(YlmDI2*Ub(}mVkDD3V1M|(omO4a2wWdju}zo9~S<9~Zt{p-0dF3Xr#S_8LYZf@4~ z<1k=&PNSTw-Smm9Q15qK1u7rXC!w_j&EBbOiVUxf3)Zsk$4Y;dOiiC$S z_KqA~mov>uDp5 zDajLRFQ8Fwi+GYBg|9~n&vqU&5?9qY!qU039ga`or7fn-lrkv7$7ns=ZG`BWWxO*# z`P)PgS98&K+$cakARZS}my3m0E{{uG{;PR-O}L{bXTi2RJxBU|P^mXiSUE;Hi`0h& z@BN{zgC<`UW7%FY3Qp1a!&wa#Yg%` zOSHwOg_W zTi)_yI)&Uc{cx(=7HW zRiW`b3FTTWr43Me_hFa)%v#J90H_I3|JJ$Eswyr9>dDnT5Qvr-j+aFQa3xsVx5shm zfR`k!gF2r&kj;@SuQH2TeH(Y_%Xz{AHtlj-Zv0MB-*wzn!HFTAI3s^tBag@lvyty!GEd^f$1CWK#_dLfp$@AHWRmxSS>G4Pd3%DLvYaM;iUd zJ!kdQZQW-AW%=1D*VRm}k&BKj>H1OUz6ZN77^NX>`=Rnu-&Q*Q6#m8#5<%sxwpBjv z2tnT55$wT)6VTYvZ|4uvThcru2%Mg6&0io99GZlBZ=h!I@H~9$*X@~`Fc_gqE0mHw zYXsvqFod)Wq=-EvZ}9;lAPW3N#;ybFaq!BBFfn5^=CvF9zb6ol9&!47MLzs@keO>C z=Q0z0f-fxK1#EJPBO#@RdbpRN`bv_1z&h&wk4eUk1WwKlv~{=XV^e_*7*nU6VDN?j z8NPzY;Pgi&KK#oH?zI}zf!f#X-0JG>jF1K7VLwqfq{N2?!^#r5gr7G-6hRKE(lZ&D z!VHUAL*=dS)svb!fZ0yzFRN``RAUS&NU#G1ij3>xUn z&kv(FDI+Lts>2WehI-zg#n=h52Ml_slxX=KP_v*&4y|Qv0(Jh*zsY2al;ux$-?!6q!OwKe8Cn<^-H;fHu>E zC?Juc@X2-O6y9wZIjOqf8}N|e8;yegEf747?`2JjmsE~pa3Khn<>`1>M1>T;M!GsA z(kg#Egr0M#y^b9ig1=0(IM1n3RpTk-9NhS?R(6aL9ajBx+%L}{C$`az(%}R)i-ZC0 z0oE@__q^UmELIsRoqkjv*eVt$2N5iZqw7ASg$Yx>3w6k#;c;$GYno&r0?tKPMlgLV z$mYV@;LO+w4C=@$B_t>`9>0p=dV-BWH0ee%qgZNR(dQS=uiw~wMGB0Dc#mVZ+b zKiGUMDwY~fqAaPC@fE{vs6y_}O;m@g9~VshA<#D6#HpSb$2H)V0R=fiKiX!{P^rMXX4a*U^TFzA5pB-(?hrv2WbIU3PGRZvuCn@3-rnW@`#t0h<@ew zaF^@AGh(DDYE|o-bJl}QuzMG#J%DTZqluLKFQgAaz9u`Y zc=X;5p0l0m6uZQo!`>M%=%|C#m4`)_}u5FOJy;zig&|NZ_0MIWKplo}s z1$X7eykeZJCCi2qCMoKyGGO-M`=M_L@wB!YbBfR5JX<9L^}=nxyo)GMWnPVbZNIoe zrTu*c_xcm4%_$?rRiE#g$uwjth_?Uv{L0hg`&pwWYuky#3RYUrQOS!m@O^cHwQ>C4#6$u}h9J zH_nvFMPqs%M# z;l$tYrHM&%HgM}df^DBT3`Wsq#=%T2p);h0ES-ksdBJ*cTa;XHa}2!K4X5cfsr!Eh zzC`tDXWwh+Cv?(NOU8YgGoMH%uR*^**CT#tjc*hl%Y%Ux6MYrA3|iBHgJ(D!4vT4>`S}jQK{#f7cAIQGtCXzW^tr01ep>*%Ys$@)jyr zT?D>1zJVE-T^T+$k7G~*9Q=#SFS%@A(Y@Yn{_-C-jpOB9fZI!%xO<;pczm%Mcm{|@ zwMj00h6&~A8P%WgH1TH>D?nKyT{$6pt1=|ZU0r|TV4l#6pRh@y*t4Z8jV**LSW!0IfV_WKJ6m9AFgr8V~J!&8d|6xaHR=il&#hf!w zdL_c^)--$M6}PyFmC{2q)Sj;4Cxy{o%Ox``RzR%5$B)hiz83nxFw1Z1H4_|uoL=ff zW}ibi6mI{q^=Hqe?RWgN4Y!xUvi-uyt2*m!48CLNKN>WF{e5RKB6BiV%^uEp@<@uOA)zxkcs1r<^0*^JzRHZ~YE}uutvN)fej?gY%*ge`nRyWG$&NFUj(( z$UqQBKoG1jgQbBi1V z*UT(TnH;3zD?wTbQtgX|PRsbu!RSQ&1Q;D;8kzvZ$m}ZhFe4%NOad%CGZ^?;s)Vih z8c(_OQn12UN(Bx3HR+nZtr-08g?;)1%&NzLHYdhL^S;?|X;<8{*r!Jp1J<+27h_vB zbWcf_6ZiO)*}7$%p0gg9!{WAEt62v=QiMyk=toGN7J2?O%Cw#Zn&ly-*d5MYYk*?+ zM?B>z`hL=&TZZ3g1ba`&$8;rf2@5mE3SRN#FX<=yQx z*U@V#piiYTnVp__wr)-uCe%@;%CIEHd(>ih|C#~`XDW2Goe%s9vifHF`<8pv)qiHG zKvRp9+=;fJ$}|h#`bM#9PQ;ZO!iRG3A`S*{D8a5?JW;s_;8DW(O5@sJ)*>mpDxK|b zX~#s${KQ~?zYg;etYRl4*mxWU10=DEXxN(p``7y9mlAf0XdI4klLRZt7PW3jc%7w6 z9K0g_2tQ>?J(dd030aOD4w_@?3{;Z7f`#$9>&vq&MOdb!N;WYgYXHcAHBBxBbX*}t zM7aIJ_F&g{mH^EcWRhMp!|$SW0OU1ACE8A4eS_=k+M9NBS6!AK}8*jJ&>Hq7%JpL%8*1;tnNu#ZK1XEA0BJ+}9 zjeK$j`&E8kaPxO`+&b*XY#=w_l4r1joa6>dDP-f0M!#Y4#-ftRZ2ZKFLhcH+(j8@q zLxlh~BT@b=!b*aZg6*92{s?t${=pt0F=V zhX!ha*w9SKz)o&Wng%G&sS-2i`CIUgDuW_0a7=;@c2 zi{KKm{^{ORJm#im``zyOG4wQa$y4yhlC{iac{=_sIgx5IDwwYNDPex=XD+GNmViqB(Yxqz9oXL;IzlG!0pLRi{qn{woN{0##bX5C4E#QeuvswUhMb z2O?A*6SsDDuEHih=LY6yLf1ZoJOu1dI7z@&PWWPdsB*5sxRMWvtH0?mTyA#9rw@`( zX)8ywIsG@Aod0kDDbX)5bDF>C3Fd(?|F3?M$>kodSgOOc8I^i0Yqsk{(RVi^jMwT&gkG0Zr5+9 zlhW0MTV%PKxr+Ua_^Sl_gyGUSwjC&?EhUoP(EMWI zgTa9h{MwZ%QP-S0%^UOS+Q1sr)-S3-gR0%5IhM>fPS5S(-u+WQ{&#doH3*JIc>!!L>nNS!* z!vi2kv?2jYi7^TBDtIoeKC*Rj+xBA_bd(i+ir*=x#98E}1jF9k{fxJYdz|kyj(4BN zDVYWFIv)(l9*sl=H0x^;f?mIR2srFomE*Sn-B#bm29;b2UiqwZp^gOLMikE-xAvJj zHm}SQB=m?&KOtuwRZ64_beU1HY<^Pms`QGQF-$DR#Nq(qq7dORH-C!BLX@1^hT_at zK+o*fj*0_EqogRc5+x>&qG>n*R*3yxz?3ppj5o;+WbjR~(^V$eW<(clEDqRhvBHx( z@sJ-~@Iig65}}K*aJPjWvW9=1iGF(WmwLwQ^-f(&A`qwjK0lixwS7~k6pmlx!ST0L55EXq>jx+*)>?&GdU7{!0rXHZ;1pg zn3*{4cC55pA5``S;K+cjKN#%*n@pW7`Q zGtv}u1^b=PD-M2HY0l|w|1EEa5%V&1gpuH)>!$cR*oYzrQ6A*>d((-tudd%BHnFuE z8E>HAwM-5ZKVqgQW_Ir_>0jUrl6~i;WdLcr>hTi6fVtGu-_#H~Rj?^pd!gpTcYRA& zIDL<7fb?nC4FM zZ0B?FriF~h6p(K`P|xUaI&gcoL>(mQ8=T8md@b%r2U>JPAjM!!Gi zwSRNE{?&?|(JIR#WN(V7+jlH;qlw6DwEWi=kz7f}#=ofic=^VNLZRwuIKr6KY=R}_ zt!?ABiu%O6bhP?~t3*%l!F!=JCSe*!579eq*tp4yx5mp=ABiK2oZqU^JRX)Ej;x=! zsNzht(Cwt+dp%E~)Df}ip5e&V`E}|{i0W;;_@5%PIMdg=-kMU5;<>Nl5kpPBVq*jA z$tB~^c6%rI|8aEIQBgK;AK!%qmc9w;SURK@q@;FX>F!h-M3h#fmtNwbL1K{(DQQWi zK^iGVLRv+-rCxsTzxRF4oS8E-*US~4?{xj$Tkf5Z$YT15a6x(#+ z0#f=AFrN(wJSiQ&JHGUt=#L}K0kz|g<}adeW%5D-k+3B3@_7gjoa*;NfLG2d4srac zd0|%b54iEfT=d06MgGrsjIimBI&1ElFAo)Uiaw+|Ir#IB9(LQGme|(Z~FR z0behQ`1GSqyGP4@@4>;9R%zp0r-YW{ODU29 zz_+t^a+vx}s~_ZrhYLWK^BILJymt3y`u)oQ@^|hQS0%g8m%feV&_IvVPggl0ii&`s znqNr-Y@xJC;hT&-GodXVCgY(rC*|Mjns&jnySqaixaTjC{+Nd4%5pw|L6(WG|1OTn z6rWU|F0^lq3J&P8qKHvFUSw{_9INji$v=~=HPK?EOTt(q?C=!N*ftgGY5Ka2VW0d7 z^oj6#CO0~jPL_#HU7=`Ib(aTW2g}L-XuXt~OSIJ0L(w4&2FI9awRDCVlt5b4TC{wdjg!7yuGSQhKeR&3rUpB5Y*}h)q48`?6slm z9G02lRnuOEH)2zFff4Oql(?(_n|t@z;|iY;cDNOS%JD=osYC-;V`m2GDYSR zBBkN+e;hC3y6b2lYgVCeNDX%I3exP<<}U8C#CalmoTVj=1#*tN#kZpxz1>fXs~&`r z;S_qXEm@M5+=Gh$`pRG6u~+Mo_y7PTq~640p3bitxImgk4poPDk>l8{vKTbu)HMTi z1Aj43UB`Zh?cQ7w{A}*X)s1(2uB0)C#jXDcm^RbpK|Czwg?cl)XDvj(V`CWx%1fxl zpwNi(m;y0qGhGz7>w^dbAaPo0cpjF<&y~9OGPClP2BE1dCPJpeG_?s*V5G|buP<}D zx$mM7fgr&u@@b!Q--e*hllp;_CEs5D1tZBpj#5K0QI_g1mv_Yh508KVdstJH+6Ae7hPKOpGZx>S zfx^dp_roS!A+f6{Hsb#UqfRxrpLvuJKB_xC4jyryq4j28IKXs%y@C|QD%|#^l|M`q_(H za%L5(rZ%=$9g|%5X{=QoE+I=Oe;_b$Zb)i3Nbc>nzXfTG@kCMCf7Z5WUo}5Cq%Rp> z-{TdGuO_$aF=ZwIhg}47U`TaC$xo5LdM&KNzugzRSR;YK?^2ju+v@utAs2r--|o8T zAPCoL$X#=a^)wqwX-8@{cI%$1@?~MQJ^L_k0Ven;snj-yLxpsWW&CxP~zF;PfJtb&@ zk@s3(nObvazQRP**y%;69%w+bMVN)aG0uL2YW4OyBJ0cU zOU@`-hQqY`boOeC`=(RRrq;w)lXjeOT z_U<|w8uk)Dmi&x0Fw&EHJsqYoLdW+S@2AVNwrsu)TM>g76WRxlI&=Zj%b3~RmsJy7 zM@E*exXEK&r_^8nU2Aj4XX#qBlcwS5g=Vsx3+*5M=|q4-f;|)spBER7E@Uu}WpW`= z)tqJy_4SP#oDkoyoJlKe(C?26xRwsrCnHNdx8Lr$7d~E+HqEr~>M!aF`=nLO0wC z=zI2$QXI)2q>VLRFzb_)Q1)%G;rg{YJf@AY;(1)xr~&ZpH_Uja2HV7>7WEK^{lYMC zL1*MQj(j$F3@v3L~{4J*8b8%8E1F2vzx{^gC`^rhc8#^kXa0X;2hrEJKqQeB6VsR0_k2-5?@zsAihsZEiu0H9s8Gb-t(+>- zyG=vLjk8Y^c7Ne|e`+cEn5NZ+ujJwiLcA1-ymTUAv5PbZRV?-A1FptJyh}DU5L@>2 zl+*`d<@SOF8FD#-GKDG1t}l1(g_C;|?CtpkEoUqvVew_buf=!ZPGtJiPYz*2!9{L% zO~Wimk_49GfmB9vA|8bbE`Ia9V#051v-J)PjZCfl#=oU_sIo$y4L4>VIT7e~_DI6|Sc6AJBLEc&3fN0;jy*3Z7rS1quBoU>vGquO*f=_)0$% z_WXH)KKD;EUAZ4meA8#dXO$g!GeTdaH$KZXeIQiuNrDecq4gT8aW-wA7igPZ!Nw!* z^MyXdmrzi@_o~U*fX6UFyL3K0sc>0H!3%P$6b4Pxn+UEa;J}EHYKHx4g8G+B?Q&k5 z;H&h$uY#8**fj{vl1bO)jvbzAx$RG@zn@-SpADF}Tw_|rNdjq7QZk^sED1&Pv17c( z+{NwKU*Q*1*PKG^*0)_Q+~>)g+tx6VdU+~Tl%%mIo2lb|yU_z{PGe2MGKpp~++^G1 zcSEy$!HNcNsVj#K?$JC-p&%I*uS+5&QO({>FKL2heO3L0;!?enN8B!da1Wop#(bs2 z`BVH;^Mjq?mpa{2HLlIhC&2py;xgk$V^iwTkFd!j(Qr*>>@{_DFDb5}_Ste%*AsHcp6g}i80d=uSyNW=O z(-FYW0c5B+48n%2D$PVpwZcyv`>{XGxFGt)uMGV(I`kiNCnXPIN=})o%0|uqE6##F zj`7{_VtVZu8JL-*_==7ED^!azWhYaMu~2R5=VSy7>s|2R@ljt+>wD1m6#<1>{yR~& zq0JRa2!lQ5@yP2pnOE2a>o#_bJf-qB60rEpn^Q_JiX<8TPcXKAFB~}50{quw%Oe8s z2<=5GPrYYgKhF;Wq4k0K&3dD_Oo?g2q6O5x27C0EF7Ec3usJg-NIpvn^XndELdDya zodm1yu+M30yF=qx+^G`1FtsSVhmh6x;#oiiG_^2EWL@i>B?tTbjLG$U%DJBoghEDD zNxU0y1Vi+VzBdAfg1JPoKPW7sFT}1j!+1Y1hjPpw3`e($1?=K92U`JsS%pi2IdtOE zf*dA9bhsIT99xP2cTS@32c!)XWI+AP@v`p0(bFxEz(XRn{$2?-f~RJ;VHjH8H$hzv z@W52>k9WxJDm)}sr`{>_Gdmms6lSU(@ax%B3($Ho^+J-_&o?aVnKn+p0G%cs+MZQA z-|-=yv5%7E;K*X1WZ$XON}Yt~I>rRv^(nsT&Asnq>%&uda$2}Dc)lzR-D=Rx_$RydD>;)`kgHkB%7GXBlJQp@k{JRE?+s z`ICG5k@cvq*LUKhl8LYvJkDwmw&$S(YWFFrwS0Alu%A+@$y%|{YxgJkY?$Y)zp>Zf zr|Gj|+PII@ox;8~RC>$wdPc#yD!&lJ>FnF&4T9|cOK$orkXgaR>)};bAeL~H4owhv z(KE_fnw}||rf^$-O=5|JmjJkt<32A9mSGs71T6Labk*5QGbB$GO8Rz=U0UImOls))1Nb(!F}))U zYP>7Gw5|Q`&!xQa!d!vr$UFJq+n3c!q>7+0o0h+Ea#NYJ8&6K&JuP64Qd{4M@B1UC z4?!R{pvUjf;> z9A+68AY?p>%Xbcn&p~v^TzFC)HyC>OPj56H79ovIH1EaZ>t7Z7vxbj^oZX79u~URS z+iX0K96k+FHQ4(Q1mwNxq3aZ^d#21wMoN6Vcs1 z_+Ek9KS_q>YBC=xH8UPN0Fli8eG%O}6~^C}X=^3S*GXQw{RkEH0UTp|c}q1AxW|qE znAI`MpXLpiVu&dCGfojnA(^Jfxxs^tQ2;tj_M#n4kAj5>52yL-A)de7yBj__uq-v$!kY~%|Gr@@FhU%aj`$PZ#WNs@ti;fA{c1c6{0 z^7?@a*Ze*LG-y$;hp4pTiDTQadTO^76$hnjpH0(prxOmMp!8!f+VOTDEGIg0_0Cdk zovg*0O6ebL`1)zQ?!_NDX5OqXp})cFIS{cAE4n*!G-r1mJ?GgF3Q>?^1v#A(VOUps zUJ~Sm*{hA}zH2XA(yz}rL%2D|7(Z+gJ*6>^rE$s6JF99|gM>@h-th^3)uIZL;TXF z$fc$Kr^_$IDs*e_(K5HM+pv?8X5;Cp{#RnAv#s8FpF2#XxFB3(>WR{!C)q%Rsz3;ws9>sNZ>)gd&1RBc6G%4hyOy4J$pU033Cn%RCz zqbzWXvIICp+)Q1ypqA$GkCB_WLaoC}HkYrif*{NMS6!#(XVx{jD_*|7Y($|d4s{y3 zuix3=gz`%6@BSw^mvXI~!_SJJJqtRj{r)M~TKah7VAp>hE5XjvrpS9gdjBtWjV2iZ zWv4kUw5Zux|8Jjy4W%2lwzAPEJ_=#kw{RSe*G*5kiF;l4LJKQu|ug*foNVxUrq#y6#m<)gs;)4=S8A7b_A03@FmT zNn%{OW(fsTd#aW@JzL;4dQjrRU<}oD#M;!uxM)y67WmUN@FA;TX4-A7r8A@gTVkyq z-^H~D?2pkqb~LAKX~ju49s8tONCs9gS$4Ggx6!viW$E7tR7JaQ=Xv;j7QnaMKYe|+ zB^$*{0o~|6?dFVrGVSnyLW=&=vxoL=bbZ;K($jm(yWz?7)-VjBwnGLFMgyKF$m#fu zV2kzV+h@TYPIHCZ4Q4ir*DW#zsey-$kA!RZ2iZT?u@{mUwGqNa&)qF7|Bijf`!lDg zfXRkBqM=-8y`~e^beq)|Eeg`~?*^qrTk(?p4Vt+n_O-R`o-wCq_X@f{9OQO?y|%HX z6J@H&ob?p;LU6942(0gWpA0A@H z_4)-h+3szG7~NN-hVo`L7g4iBg~A;F>dT;`yO&Kr@DB>DczDa0VyLuc$iv>Hsqpi0 zseB(`Ducj&30>8Bk$AJ=B!Bqw=5SHQsyBiL@^XLIrzzuOCXFo&s6S8turD$8F2pb^ zb=7ZtXalFtthThKDQT;(S1qn%dM}HWe^691u4nMgmb}WBMi(6cOq<*GioQl=u^k}< zKO%r`;JW~WF=jjLicZ9l#IAL6_fD`_dIx+eGGtRVzid4&#cF^Qrs;!`yCHcEtNXjc zU?b9Zozgo-D(XbI#A{DBzd7mt2rU$kAckmBhUXU;OZAB@En*nvwCA5~k8^~eSzqyv)M-o7k z@I%EM4SM1p41IN1Ifse||r2 zTG)L^T!>pq$4$#c0;T?W+wMI3vcyfl>x)B6vtfJ%LvH)?!2n4+K9d(T`u>O zG&h`W8@KTWa}4aF$*-zjDEt|SaE+9r9~*g|;!$D-f4FuheJ$;LHT&jpc}|9JH4IVF zYR4E=Rp%;*snG=EEQ{D9j6kb>vg$;L2twUAP!kgAEW&43QsLVLU(o~ipm|^Z2p$(J zy*78^QA00!@)dmVYDGhF(Z@FS@IRp;^H)eS;x!@^y8PwuM`-Aq4RZKEkUWzt4LTp6 z`t2^(nS{ZGm}4XHZy`~OQ&`?XelJ=?n(wn(ldN8afgUlsr2*YSRYKE6`~WA`sw^>1M;kwv5|KL|cVB_D9|pgYRpoFw|+gH%p@ z*!3HXVZK1hyq1R-?~hQot7m!DCoFgd%S#LhpZ>5e5?mq7z5uKS`F^-|FH?I3pMpdb zjb25oD@%wB;%r?>y`qkgN%-<5QV<&L&05_JMGr~kDJ#i8f~;G~Xhl&ZO3y3s>X%88 z5Y37ch7_kN$ukb?K36#U9>3sxhU}`{(I^)`TC%6WMIQ*l+Jp$9bPWtZ`TEEiIP5lA zqx_4gd=*KD?JS(ow4X~HMuq^P#(7d`Q(R=w2UILyiM_rtJ1wJ}$$wGj1*hpseRs*5 z4HF49$n`Mbm#0G{5%>IW!30N3M}2YAKI;v>=LRS4!_S~4x;NwT3QoA{CeV$8$TI@oKk z`8`}n(3bx*X|uJyu5j*6shlVG1pzBzNep-}{@N#oZy+d-z1or-Bf5me+CP)OZ#0G)257drB5Q+aP<>)c$?Ax-BXfz-tk?R# zyrbpYTNLGe4i-SDQdRF7Fg$dnfNlf4?PMn^>I=Vp&|>s69D3Th7B|z6U_y-;jxX7K zpm}*#6NS9C0Z+ZUZ>oE4cC_j5F17t55-?N_8uZUWZ?(9u2h_^7HgXK!A@2>XXWdvQ z1n#kBBDSZ`5f5o~f*x>ei1~5G#?5{w3><0;e}vd_Z~|6tM-) zF)#eV;ivPc$eNS<;!nq_eOg!8kY9upbo!Ik*M&3F^SR`-J_1*y)msoIma1^Cc(jPZ z{<{G8v<%TNGm-TeSu6g5xB6)Zw%^Ls`V{~6cx{YKX-)>b_lO#dn5aiTT}q7VO;XlB zJu2I>eYtJ&Vi+ zW7Y`4!zV;~<;}uak*$%BlDULwJ=97Ribvf#@EQcDZ#RK_jW)-p#vBoN&aGFmTQGOD z`-%Fp_#gjdHpo^G^AG@s%}H0%L^QE7;P#O}K;#RlNGeu;UZgfY*MgPcp|+LLT|Tr; z@E2cOC1csk7CtS$c9-J&VST$|G+S{2`}7grf>V-m0z!#9#`pJ9Jic+$(Sk2LrCpCg z1&WMdt?6L{t31J#P^HUO(sX}kgMo}P2!x_Xz)r-n>mw4e$fl>^m#{CBL|(}Ix0*^r zU7pDqDI4@n>}Gp&GmlSvoA{SHv-RlMEHN~?^urMY8^L-{xvzY6=6Q_%VCFSRmcjl> z4~N?XydO^7n}A+%oC{T4Z8ZJ zba@7;b?9CBE$0|0h3w4EaKa{~Qp2b$0e22QK^OxBa*<#ApK<)_E7-=50{($I!}wX1 zKe&id)^}x!qj`Dau%~6kPnm%>CEjIOO$lgS68eq7;8T`cq^|NjDJE6e+b-5NC{520 zeNd^RQH*t!gME6x(fB$6909Ba5r<{gc2PsD^BG`Tg9_vmIB%1lvTU(FCO6r%pEhn1 zbZ^xdabH&gg$B%W`H*L`g6-nBcONX51iNCTXGd?y-BhS%2s?qNYh2rRY4yI5KY%xlFiXI8(BcYHtM<|455htvI<6>a9; zvF4vx{p;3eJAR6^Loio$Oq&^aGyJ$(hEeilKOVv zGR?P-58{3czRknw%XPg6q~i8xXVew~%z#{I8apDhg02j&&+6ZUzA_M;%eNPdhMLj% z$5%gVY&{g6;lM7n6^tx)*@OL?>zzAT0ZP6Rbe$tJz$Uk8GTFIdP9H!36a;Fp)c^Px z@vW95-zTXZJHZEC|RH0oXy9ZCb8FGFZ92j+&n|39uI%^|Y#H<*VkT zl+%kJapvXQq?TJHbmdpz0=J}*9ar?hj8DI#G?=X{U=|cww$W5JM2wgFd#bE*oIy+% zx2K2|?L0-=O*>0(;x5pgA96_Z`687j!d?&~_q_0gMF1m|w)}9dco^}%-OwDEw5nF; z<<2yYKL;T#0`UPqcp5CZ?L`$RdAP_@j$iD%-chDhm-_E|ZBoD|Xw>es{4XxgCWEf0 zYh0>ko2J?g+f#9SzEiqNZYS5;9#EnGI^4dnPc7jSZrbo2!+&_TL4fMc3E%dC>gCr+ zH4S6a5m1jD8!Z1lq_;upS?nP_;>ANH^^MTMViR}JYTc{SjrtQ1!a?8zE{<;Z!g@Q} zMu1}#p&YhE`L2-}?b{v5A>!;8@7FC+4OUcw>_ZSQ%+Cmk!$A7#t}i9+nTj;tmGFS@>vqYROgv9jF_Kf$-v|}H)7Q1Iz1&n8K2p-BWVnz>e9KI zI!r{+ZZjd&-fR%iV;?HpOmz79P2Y=oQ+SVY+gB|!QTKGW4h9%?NSJ4K`-5IK9mCnY zHUXydK7P#raY6beTyK(7p^-7m{fh0Ek$(?7*dL$%_jcXqcm1wD8ZgheLH;|QP#_5J z&OT|M{e8L11uF!)kauIl~=X zcf!vslWYIS^LW`4on*tCEb{fA{z${)_qS4`1h$IR?yrVhg*dAt6OVp%(y>SOwJ#lq zJhi6PoxzT?6$n)Z`eoCG6;aOrtnj_TU-+%Cobs|ZrgyVN(qypN5=mOObTav1Ym>QD z_!Y6@yT>M1A5G~7M?{`Z)h(6ftFnl>;a)!ZD_c_YpFjDtl5vCcenB=XH?quepSY_E zU-})699?NX3WCGbrMP=9A^>iKT-1C80VE~c+!C_wTIO>_RFaatRAP(^JU_UwrF_*t zeo|%kt+?sx)l|H}l6ilZo4e87XD395-6t=Jm*xdk^NqE!$n>rKIE03)B7zj8NLiR^ zoA04R;ALP?F3wiiWfx6n9chq2}_u^ z+YHC$I<*3_%y{8`X_CHm%?|}H0QS_mcX94F8q@y7s;7uJho@0uQ%&R{y^hnQF{CmBhxBJ@NfEX0|H9+#^%9uJDQPq@d zXZ1D~o5rtKVTUy1DbW{8Z{k;XZ3lRbg}nb9-3j8L{>|%WpI621E^}8l2`*b2-iuUl z7a5HxRll+oq#ozPDOu28vb;Y1QAa6PxuiB6kRx1LxCIW?+c{G;=!u#XBhj$S7nM3(y}Qv9lak(}+SxX=1B6+{LJ92se)s%C6N>1)M?k|oEP znsxO#uMQ2*%1J$eUF-gQzNoY#Boi&~DN3DBq2loks5ASl0Z~m-5-I;%`R#5{9U86E zGHOoR#7!?BVa4Y#$st}4e_SZJHd)jLA);GPmtZ837B*j>=2FVbwMdQ7EYu{iHuX9)8%(w?FtkDG6W&*~uGIDhJ$F@UQC$GP5P^ z{ad|6vScfmA+Tzv*tGK~a&80u4fPA*0O$G2M?l2t4~xloEnGyZo~xX7dR zb%>Q-h7mRA`69G%bmFXMINj1pUHu{Zg9w8qP9mZccXvK+d{;7V0ptpWjeQb{UIQap4#$u?c3*#w4@!4nfcP(Q- zOjz)q9(wwN0P9*u8qndvk}{t{lZ;ihEG(03MM2gF1m-D>Q3(sR-OXA}tm)EAvggR% zr$M<7w}MA%qQQuW$>MnW3;|HLp0a?;JbJQt_8pi4M9~G(v(n>sC}?_?tp?-rJa3-F zg&{=0$#nO7_>bGh4$nFt#foXJzr6YPA<_-^Jj>@?kjKjQv*a{@_#{V-+f=p`h%z9*LWAeeE=Hzt2~#+Gp6fe&^l7WX3w;HQN3`5T8tiJkf4jXI4q|2LjxK^@bU&>jbuz!& zATa7vxESsP)v;1}9H^V6O#CBXgpa}ajh5Wxm^vYd`a;S#8rBi9M;*-=;3onjYoqfq zr!4|iquE-;|3Vfi9)k+N!VLsaf$3Tnz+P6=Cu0W(C*tQ_YFX{g=WSf8crg(8%9iN# zO_Eonvi=Kc5H%T~6)P-o6G1HHpadm6KwHpjWB#CO-{pE?L7h}yA_^0qr1gG8x z0TH>^qQgbCl*3|$_%pZ>${B5m@^l}Q0+jYG0R6!*k>bIASR`JkY9yJNmDui$NO9Ar z8yJ_@_)R>LBt!-u9vR}v0AtDcwixh*7EB9O^8wez%>964B<+l#R3J=~4~SOE#E+n_ zpt#BXigF-6hP&H;vM(gvXx z7{d{G??gHNJO4iSfh8P@9L`yhB0ypqLdK+n0 zywZ5M>>#`P%)?MjVx;m;KW7~!v$hvuXt5Ez%E`-;y(pgm!8xjMXJGqMC7a8`nA#3_ zMA1ndgnu(kXrHq2zJ<4en;Y+w(ler|Fw~z$lQE z#Ld>b@cx{x&*jRlHV%Bh{nwa8*8J7ej;V2rwEI2JG{E zi9!OAZ1sElqjI0w8YWTi7X#S*S?~};GQUXnlPHbFqH7FH!pd6jGXkF`Y%D)F<59dm zcamLv1nY>VAcmzJ3qaLA;~ice2U_2qTs8>Tm{uAfU9+QKdC{#w(bNK9uy71cH@kgV=V;?nSvosPpw zebX*H&*+Jnk<+4m#R2au$?9Z0qlRpBfTS$`)7VyLKF4yi&G58l0d1ksFEcg^62In& z4Z?t-wcdfNu@A+lo=n>nzoD8&Mb7)JgzqwZ>h&-r(Ea&MmSEFb7) zq`P!f(w|Nm1~B?L|IK1NlWx!8ftn_J!=M~tKyv+_+jt>#tq|#hEb+-xm(HeZBdGIM z1Xe`(CfKDhzdZ1foH1#Cy~dAV{2K&w%=HInM=T+q?KZHoaICWdB~nEBK)j4wL;8&! zXY?HV?7w+szIQN(WXi0x%i539?pd&fpWglx;jn9?<$yeOK;9odiKQYK0F=-$FWZp4 zY6)kGx^w%}ckOQD^C(e-yN&@1#2-$*Lj@Uo6$u%b44iJ(*|}2?ZNHZVha-pX$zvfE z{5crLcFWG^h22H49e_k_b}qzuZim7^RJ27|)~*FhGQ?+t7EkDB1FRF8{KMW7;?!C+ z&Hd@*(E{j25;}#;{oRR5?n_%F@u7v35L%^2n3mfR;xN4`z0Qwu!vQz6+ggLzM@r8s zPSHFnco_SA$1pUcz)ZFHFQK6@{%5#EX)O=*-vJfJLa|RB0oZH{2}S1LDo~x(o&VQ@ zU5@ka5yxK%knw?oaKkdKy%kHYY5zulUa@;rhlQY(y9Nzky$HZJ_?C=SguRek9&!@7E6zb`B+A1$4!D+BX!aC8y2hql^)nIO08pK5Xg!y0arhb&G19NihgIRT$3?MA?F z;?x5YtJ82VV1FW|-{!dR_Bdt%R;PC47;7$sKw5I`MgClvP(kn4nM-H1p2o||a1hjV zBzh=9*d!&Tea%;{;CM{lTs`gNct0f)NFRaP0 z=O>uo27iB)N|T;TYTrTbIa5%CjqK`Q0xJf4 zbQmf;___lEW!^MM@$>W$ua}HV!?yz|4t_mp1V`8)`tVpu!dV#lfA(4g$U~OZzsFip z<2u7hSpVhT^SYaIYu`Ry-&sdVm*sp-l;C$y(M1|6VeOGs`viS&q!#qvLwXMr zbM!^*5F`o)JKKXl`BrEGuT?l!o`ctDAD`pcsZN`HixO2hBRmXE(=^71SeR2!{asnY zix_&wIbN*wf@f*h0GE}EN{OXuJ6y+ms>@~kwrRV#1C$;Jl)iR9CwDbhJYhA;5v%e* z+Bm{_B($iSg_Tsysax;~^SM6 z-wY7m5rji7iFNy=KRy?=u5K+~e$Ry;kKZT0)FB7P0BEy>F>n?oY@qv@)1$&TaS+_p zN}JAlpXv9zp(9yCxgc`biO@KgIuowq7baisMRjuz4`{G`)}y9b2J`_9DhCJlblKD3 zX_u{_l8DB0ZAI!LZWyd{5mOtkjr0LSn}+JLY6N5e=9bV+k)BM%X@%MVnBN|f((a>vP!qiAEk&mnWfnrm{*`wG)WpX5b<|Wr(j#TiXRPNimdNK zU?^8LAzO+JS}l{w!vGa%Tn2&CF&}6ahQFPB0*ZLv7xu>AnYAzA^K2VlIv5U<_nv!FbNcK+2z=hEx-pu46x!O*jJqa}7!AwO6L5B9 zg`BETuR=qNBBhs9zpiH}41saDNvb71=J{+Hj%5=J2O{Is7aR@P7_#}QsRL*!+i4XK zG}!g|$LIlyR}mU90uufVgzGiDubl>3xrcu_fx07F{(TLg%};(}D@DA<9(&Hf3fWH! zBh>%wq0?duXVqAsB&DNjBY{HEKg-f35-0CFBY<1xl{IcRwg@N&eiV5;yxRWF1=-6gLx z&iFd$R|MXRahWYf&6s`Ndv6&$b~5;&UH&|Gw$L6MmuZxsT z+}qT5>y5q?n-O@2I?fA1a~fzYtCRKrlo0ei zD94iHqV5d+GlcJsJH+hx1 zmg~pZ`{bkT^KcPWjFaF1BXFl?E9>d}iY;r5Uq-`LY~rQ#{5RP01Jie`FsMYT9oxWm zX1|`llt4WV_$e9k`rpaPA0ws&*(mWx4O2s#4zPO0|H}QIak0Nm9=#$YmyP@U!}7WO z6xJ^~=0Sf1k@8 zC&Rx*TJ&vxYc>C;5(XRI9_?QbhGFVvB%cm1rJBK}dN#{fLzNEh(hU-^Z*Lm^JmP7f zlUBeujkKQk1)2170LNU?~uhXA(Y{EWpGPZKwc+>6rS>p-%DD0xBWEN;B9+^+fWCc zseKCNUVyZ@;b=!ia>}peu>^ztgpqMcUGg3J@unDqPF}I7i{CGS%O}<6K4}`I(+#-L zu#k4~<_8euvEW+kDU&*kgxy)Y^IKcoBb9;t=|37GpXP4biUX|wgHc}CK1AB!W{S<+ z!x0gzk8-KqM^t4v-<*m?dG&0N#| z>8cCV3thwBRLMm!2m5oByoPCb!Yiupr#PiSDTVG*g=~1i3NCJ@9DnYk_LF=4Q@&&g`2z@yR zb0SmBKYTL4F|3vavP8nu)N+{3y7s90QQYB#LWoaw>O;+skX-zx^^y4+kbgd| zEe%HVN6neos9mj@t2HA;U;6$nsJE|(qhKle-|-Kb%2%6TmGSXJmrrN=hy5b7^;t46 zdvIA5nbG(p{b#3B8ugbV2abL(bdpYnx3p`aH7CZ%Kpk2}I~b}1?Q18)m0y>a4ukQs zLTsBL6#cE0C?MqSX8-dY-|f)_&(Fk=+h=+_Z8l@%&GA&YkW{Q{;)r<;E5veqx5?}w zWw4YG5)$9sw}owPGx3#{*zTlFQ>8KPxl@q;4k#Vgd%yeYf?zt-^oUr7(DAu!OcLEh zvLnkA5W-oy+^Jkngf%4@S$+y#s>aZ@mCwi52d2rejjFZBFYyBPR}C++2Qn-V>ma=8 zIh1zlG2|j+22SeS=KfI}pA~p){w8^&YaOwl2-s*a6ZXXfXRSUHT{UBPde8N71vYa5^Lp#)`%uotmnbTD~kavVm8$*|mcLrVg_z7%;8rqdARyHJM^MJ4LJK?^$|jJu?H5qvL&5$4@8|lUb)K&99|;0 zSiK&;V2c-#$3;REFe^gm)>W@20bBy;A{z|4u?s1%g7KchP|s8YfQX;UU){N&q@R|e zVQaz9R^85+k)vtSqiV@fqSvrsEx?`+3aML6h5d|HpnEb&csrnLPIi=TB*pf|<4<9E zq(fFIQxB^Mvs`9*Y@n7fQ)~2W91ZzU2@XHXQ~}@1k83YKpB>M1_VgAs9sW_CSB_2r zDm05%$jvjn04@AUa5xS@5(mXZ>t^w6CWTD+QuIC9k?@*suP0SBc#^e>VUf`G{goSTa|rgKg4%VVD;M=R6_?!-i{W zUrL+yH31tlvp6_O+1n=&_)EVo%!vW3rsx^r3DkYFE=SDa_RNVR%9JIW)lE z*FR_e!Q()ICKVJ}i4_2nc>#(0ZMrEiRr0LN9>*>P6qsx^a9#Nl)0FSg-=B9i1a6Q| z8-6HyP}b%nRgWhnMu;Z-|EareB#n#`dH@-LLNd71W_@(2r1U&o zU%t?qC)jvzijVvL{7K5tQM46-9kNYgd~4J3#b@b3cq(JtdwLL^FTXdDwN3(2;D@dt z81Y~S|Lbm02ReZoAO|KdJVDqVQWw}C9rtwRu)7G+onuTDFIM!qrt&Wn<_J)M~OWG znp8@r>42lYzoDH6t`GDc{0=elVFsfvdZ>#ave0{1O>EASLS*)bYqnMXoo3|!CE(i* z=`7`;D#a8MdE7yu36AJ+ln)x3!Kafi;u(r{hZH5GxrjH`YlrPu6FktjAGGw}TLdXr?(&3laRlt^|S(w)H)6RYC2TRZ#NbxO#)e zmF_ABJ^ZG-M=9t&2-JPKCe*nn<@s+-=)Kc|5(NC9*2xXzaCOC5yToM3mpsnMR|1u+ z*rzEs%`45ND2Q1BNj)3vYWq?Xhq5vWyKkx73IT~)-d|1u=kf{5Hsy2a{}^6)ZlM40 zsR-hib?m*y58|{S1i_A!m>+D}5YByiL?8ud<4C)J%0^wS=-dkA7e-17$jcuAT3ljB zBwxzYfj~N_Sr%V=9rBO;{zYauha3b9FU-5Tb^}DcM#^?W5T}F)2g>!XsbAV2=k4%2 zz6$WN9!Z{?NO7jdaV>Byd0zYti82Z_fu|WB2O{`dH#Ue1@Zu}rl1ySIK zui`)Hy?VSw&3BMP-`9TB93)E%{8>*vg(^xYXfa~`K3s~dB1!(ZxTudvHWymWreY^8 zKt@F19qdzl48;20-)F)N-YqIOwfQdWaCCw&N6Kcg*6AT)1qS^GK^S~zzwI*Y2d=n4 z$8;|^kwY6?U(8p3PUTzM8DQg=W3dMA!ac0Sf6zxp)t<<}+&u~$3`cr@O<0#995m8Q zAvBIZs8loRF;WW4HM1CRM10WIE@iXY@kB7Ib}DZ z5dBQzB7-IEE>u`(J|y%A&;>MJ(d4PLqYLcGtvS+I{g*5%rKC__4e!r*f)Rxr0oI3i zLT%HW7%8vzS$w}ZBNQ_WUU&)|8Lxraugnbd_wURFyIQ}-ar{DT&bkOft9X(l1Yt48eUvQ^O%xGJbEPLR^N}$K zxGUmv?A$CJpH;w4f}oaI5((Q|3nXlKT8SWVvc>`-1_(deFEkaz@Rx6++1U~z1XyEK z8HsWl4rkM+QdFpqTyBG=H9}Gxtc_D!rbjX5eC;B;IIH&P@2^VWy@wQrdI%!hFJ|v8 z=aR+X@RmEW@EBX7dxK~iWuhBQ-CFjd=O~m0mE&T##~()4Qcge!q4-@oPb@x;h>Q_5 z30&-cOb{-22|HyI{@>TKTx-rN^&r$q3IoJd+nXmZY|k@bziIHs67nrYlOR>h)GwXw z9e3h-QQcZK?XH}c7fTh9w9Go*pIvBq4YN+ZE9zQ{VcXt+sXEQ+4kidkn~W{8mNj3) zbL%;hKo~^MntHKy7io-)L8sM~V0^9nbxHTW8bKuR!&ZX81~6x+p#+f#Kll_)oxAxB z?bBI^m^0DTB+>qWqAysYU!T%Jq!K?WEhEF>S5t>KYt zNo%Z>1SQid@<6Gb@IecS`|V;2PMmoj>MwJG?Xj@&2KFXeUg!G@)=fi!HX;eTiVrC?^k8@8UKEk9 zAjx0uxlpw_ttihA;c1Wm{uFTfIMg=8xY*kdB3ewniE?n9=-v=SGAi33o&z=9w(d0L zeBGVa)DuKc2&3!HI<3fOEwrTVss!&fqohb4WgBUThlP+%mUB64B8)IVFkZ$=8m=D_ zrZC?`5NS~h9cuAKHo$Nd34&!9I>RZ@m4&-J`U)l1o>khRjYwNT-dI30jo#Fol(_XhFD2M;=-g$H-vjRa3 z#Q>7LHT*xV)uqd28lNQ)1f!6-wR#8wF4#s82!#4Y$7+d}Qp;ZdZKG@*Mh7RUOEykz z_~DdMgC}Kv+xyA{0S1d`|5?t*q7A~E42Qc3LQInX-98U)HV{Oo8?dM{#PIcwsu09@ zel|~`@jVZmB^d=!G97Pp)qKoG(Z5Cr_tFvLlt#v#VJCkVki5(Kjti4lbmggPxH zI|-sE%)qHg5Vy>7RGa1`p7x;r}PJY+AYXBy5N=-bN6paxUpCT?qf5?ne+d z%Smi$KZkMA3^7g`HS{1Li2eK^++jlu93XIqfFO{!6NGYspwxp9p&ji(5P}y;zm*`y zzS;I6h?vPsH#3W6>a!pSx4}qhR8qVITZFkx1%g=a+k-G0gz!Tkh^7Z|+BkY#4D|-X z22ssO$s|UUByPH|t2Hh3B_2Lyo|HrWJ>NwTcdgsVP4CJ!2uXEih9i2|G|l;%kM1p= zltG3dT$nhK@djGE)>Mq~TABn;N^y9IS7|@?aym<5am7{-DoMCFrLRyR2&I+aA2n7j z@vF{y6w5eC;ur@?KfFi}8Dh|b2xE+kh9FKIH4ua#@WWNLO>1EY#o&;|TEoIxjA1yv z>>`M1%j52$Oc1WuQrv7Hh=9ks;-GU*rAVwjw|&~=#m#M~7K0!*}s zOLAJtoSP-9z!#HwKR%KTLZ>l)ra8F=$3(4;QhP`DPNKmeatAt7j$U3yiHh;iY0hw{ zX+!k9(1LJ^ZhN;=NeqN6o+A;Y2eWwAO9v0V-otbQ4Q2RFPV}hfBww-L&$32t$OGL`@Iklu-vkTs9lV932qE z$H9!0or00Fp@-AP(PuH}L7cM|qaldXAqb3=9fC}I8${zLb?P|!Vl6`N=|hYvxxp!lOI3ZXMBIgHjaq&xolSU0a2xW-#Cj9@2K?uTTF|biK zYqd@u|Fd`YxUIuL5H1Z#CIk9epxXcsL4pAC1=1xK@cl0ni(F~RBHgm!Xg=@^gW2K! z*dOn8qkj_kivfm)l-el8J_vZ4ten%W);#|YgF1-wz7N7XwpxSJ!a>OCy`se6FGl~A z8H9XdI7fj%$2z2ZKFJ^^W)M)+8N?Psu)+YxSRnQiW2$B`W(q|2EHT!NQrSUJ9KOd8*70O zh1eVfzd2!8^t8A9Vgx_ZK%mp2#2E9ucUo&rfk2_kAmp3VJ4b!MrpYXZ^TDqUf+YqJ zOKPN4f$-@UgVUVgSUU(^DWxIhKMDi@T4LzJ7zzYD=1XhK3_>sGP$0S|2}JIby72Pe zVX$tL(%XAEgP?;zVTt8&?^PjaAP#_eiE)w=V+zSZTwb?>weju`|#_5!gx zh=~G$#B!@P&IKR5=cpDu=B?H;j8R|Cp+NM{z3Q~SFrz#M<^pjlGYA!k$3Xh#JI>-D zB!b{^?<3G6gOFb9ytP(pjQ<1an;&%$#pOM2lmf@vL5M#1%|Wc=oG)vq)W<-&<~7c< z8VH&8=H(o8T4WGfV-#B{zX#Ga@3Wrn-L7JA+#4SIPD-^9)j#LgKze4$1|pXj)0R=X z6Ac75?F1q%zSq+^3?cgFO;7hGf#Ay8V_+;0#XqN?F6Gj>cppf|JZ5naT4Hcz?E$dQ zAf9jEYke-R@4XJBU+%IkMoNq@`6tXM@oD2Abl_VBA}#~zmd{valpz_2kt=KA5rME| zbbBL&uBBwRb=T;=xm6%|72`OV3q+k#s)dO0#uTDk9{V50HS2>kQnHg09s5@6eIrD@ zmU50awp;hgTX9uOON@~b1U|p)ox!ftFc3wTh|*BKlBQ;bkg5s<;=K)s1#A#M!2kaY z_V|SB@=7E`P11T!)5P{TPNPOw02R6zOwVaiN?ZuSl;QZ)xjx>$+1tycEJR5N!e2<)2$vQJdKgTeqqj=sD8#|< zWLzPy#}3BW!x$qF{>Hi6r-}!$>Q2Ly8=HX772-O17lQ6_MhH=62g3E?#;GF^^f3ri z5~oZ85f1*))^+jrAs`T&{ik*n+voBE!Q?s5&4t)T6ouGDg$SRXI@iO8Hz9Q} z9`!J~=jK3okn+~KxIn0np*x*roHB$5?`B*L-w*f?nh?Vr2qvLvLNFErA0qO`C1Uy9KMcYA2Mwac zfj}RgxI)}M2*gEM@yIt5Y;miTKxI~lVfcJ>jaV)p@4p;|@S8sLSs$2GtMezdTA-#t zj4njsL+m0UF+_fR{`CF$_1;+&$K&^Ruf7e)gXseTQH-*V0^y^inxIM-gHFaHg?OPj z1RC*jczK5RtSzCHtU_7k*&V}Kuu)T`>~5P!zXHh0Kmq`}8X;+^NZCj#Si5AcAsKO$OcTRTY;+QKg0#XC=ZN(Q2&Aegz1An>D>lZ z1i~buypf?WL{^A%8o?F{;Yh2;#jHt8LuLD%pzMU@j6qET$ROuvqD87GIpg_57}+cQ z5MjQ(yXV<~Sam-0eGL29_LzqIuP5KlBZ6=fiR7D#gOY1Vo^5@0_ z`vg@7audipaRimmaQ+-rNFMMX`qF>Ef#_Tw+MtF&XuNgqWYEhfoCqW$4ZYrn1u}$Y z*>mp#dBA;O1O)ovb8@yr6@kEqkV2e9D(Pn!`^++Bq9shK_n95?P{b#3AS^!V4|=ym zC4nFcvFcC=TnIvy@*+-kLRV?9MJqN`VoM2N0NP?v3lyq;K%cTjD3fe1=ww`InEy> z4#)%UgGN~2b=iOLdl=17RUqO@!=;E+;zcClOUyD{n34y9MeDO*pPw4Pq5k>5;EOg$ z9ZVi@ABaBiraA9nGy(!q1Gx~tYebJkAQAvb#^_+AwIwJ5a<;}mi3))$U#PxNb7dPG zU5DeVYM4M4?X*+lAUH=QXFGpD98iZM`jF=0OcH@U_^YH^p`r(&seFyQXIUcFk_d@J zT;`_y1xxrn|Bw6!*cbZIc{m*&6_?e@I~Yw-OCU6yk}=o1n<0&mNJuLZ`Jz>k(&z~} ztYm;fngPmA+G2x6c~a+FrBTW;sH`kH+m2cy$y!=D*ZBj1hqy={cmRWEIQSvZPO~Pxo$0CHSCLl6gf(J+tadY2s1TkNfjs=x3rQi+C@jRgg$D=()v-fRQ_i6 zr-Z}mRA)JV`$GOJqz$9{@H{cz-}F12O;J-I(ua3L7&h!Wg(9{nL`l9jdlG$00BOL! zW>GmUP@A>DX4N=CQN%J9X$LlZv7H`3xpIq9cBGJ%tn4!H`2%d@B6*1a8u5ob*`B}b zz9kTdI!GgMA`52be9)KA_r(u3AK(tY!HPf;Yr!H+NA6=Hn>@i_c?f$&A8 zXA#|PhUb<03HG!*NB_ZYK;PQ(EJ>Htm=Xi6>cJx#?2mAq&K58@PG6LYG;umDfklrk z;_dSf>aV0~gIAkgUaZ#_&%~?!GW@$!@LgpQw;DUKmq@L^rK2|Z+KK}yT@xuf6iZ#o z2vHc1rBY?`4hLRTanu%_6Z6S&meFhHUm=f!uoKlGFD~-&!+L@7IK0{g@Z{SrqH2Vl zgtcf#VP8=av5sV_T5{NN7Vg;FLpo{Af2iiThKzGk8Fw`Tz|p|b(G-}2ex=AYank>` z`O}{E4Mp7+_QLvKY9ELz3WOa*QTx@xScYj(KW1O2qpBTZ)JvY0H9fW1!U| z0pv9A1VcnJepFZ@ej4C>HNY}cairf2_Ai+K&i(OG_1HYD0`Xv6R3JRAeA6vI(#ZWr z(eb%N@#7qJO{PtsQq)lf4*H3sHwXvkc(sdRxpixB=U`<@qB#YU8fD+lUb6Ka?@m^Ey7XXAmKPIV7z51~#HdL%=VvJy z!&e^$8gT?mVzbE5JLjL`K42`&Lu^=$ zBubP--N`_LX!=j1#Ddcq`Lhf4o%6>JkP2=6@Z#~fvOu`iu#2chiiM?(W&KiG(@lEB z1s_5xPv<0r)^IA4d=M4a5{i9fCZZ`epC+Zn$f#*{s&bv2?wr5*ffrB5v7SCW8`s}r zoWGv34u8OgY+5*io;_(2US`l8X-TVBaI%nq4uW{)J#Lt>w1{Z(^B8#hg85e|NaQ~^WL=ujto#!jyo@N*P#vI9!|1d~v+3DcR&Xnf!N-48Bl0oqC!QSiUghvHaZDCN5L|q zvT!u;t@Cf|V}}Kp@f8JP;XgZdNB{r;0x+z9YYYN&kJv&Cz7hl=2tW{kAb@oV0!V@Y zk|2O22p|apNP+;8Ab=zYAPE9Uf`DP?PI4TEVJHfIPp!Ru|GRb^1OXC}+pMY%@gPxz zXbA++5(uCr5I{>HfR;c2Er9@90s*uH0%!>Y@H;sNKrPn*C|y|wKxxS~07_f-0Z=;P z83Sk}h7dq)N%A)U3VRZp5I`xnbpTAZ#1R6hC5{k4ZOzR?1~9ww;F19p&b(Qt1U?

D@=MHWF06`dvf}jK7{=0f<;)x0PZ-^e6 zrhrH-Yw#We45xVLB%>){EI%f_9Znqq%u?4QO}+;+m%t6qg@siB0000

You can just do things

-

Ship device management changes in minutes from the tools your team already uses, like Slack or Microsoft Teams. No UI, code editor, or Git client required.

+

Manual Click-Ops in legacy tools make adopting AI risky. Fleet turns device management into AI-generated changes that can be reviewed safely.

@@ -84,7 +84,7 @@
Propose a change
-

Describe a configuration change in natural language using your existing AI.

+

Describe a configuration change in natural language using your existing AI. No UI, code editor, or Git client required.

@@ -92,8 +92,8 @@ 2
-
Peer review
-

Review the proposed change before it reaches your devices. Inspect the configuration, make edits if needed, then approve it.

+
Human review
+

Review the AI-generated change before it reaches your devices. Inspect the configuration, make edits if needed, then approve it.

@@ -106,8 +106,6 @@
- -

Freedom at every level

@@ -126,7 +124,7 @@
Real device choiceβ€”even Linux
-

Let employees use the OS they need, without compromising management and security.

+

Let employees use the OS they need, without compromising management and security.

@@ -134,6 +132,58 @@ +
+

See reality clearly

+

See what’s happening across your devices. Run live queries, generate reports, and export logs. Answer questions from security, leadership, or auditors in minutes.

+
+
+
+ See reality clearly +
+
+
+
Cut the busywork
+

Get answers instantly with live reports. No waiting hours for scripts or manual data collection.

+
+
+
Enroll any device
+

Get instant visibility into devices from subsidiaries, partners, or acquisitions. Enroll endpoints without forcing teams to migrate tools.

+
+
+
Tell your story
+

Track AI usage, shadow IT, vulnerabilities, patch SLAs, and security posture with clear reports.

+
+
+
+ +
+

Open by design

+

At Fleet, everyone can contribute. We are dedicated to making tools that are easy for everyone to understand.

+
+ +
+
+ +
+ transparency +
"Why is this running on my computer?"
+

Let end users see the source code for exactly how they are being monitored, and set clear expectations about what is and isn’t acceptable use of work computers.

+
+ +
+ Free as in free +
Free as in free
+

The free version of Fleet will always be free. Fleet is independently backed and actively maintained with the help of many amazing contributors.

+
+ +
+ An open book +
Open source
+

All of Fleet's source code is public, and our company handbook is public and open to the world. You can read about the history of Fleet and osquery and our commitment to improving the product.

+
+ +
+

What people are saying

@@ -1424,36 +1474,6 @@ Get a demo
- -
-

Open by design

-

At Fleet, everyone can contribute. We are dedicated to making tools that are easy for everyone to understand.

-
- -
-
- -
- transparency -
"Why is this running on my computer?"
-

Let end users see the source code for exactly how they are being monitored, and set clear expectations about what is and isn’t acceptable use of work computers.

-
- -
- Free as in free -
Free as in free
-

The free version of Fleet will always be free. Fleet is independently backed and actively maintained with the help of many amazing contributors.

-
- -
- An open book -
Open source
-

All of Fleet's source code is public, and our company handbook is public and open to the world. You can read about the history of Fleet and osquery and our commitment to improving the product.

-
- -
-
- <%/* Bottom gradient */%> From ee6b4c5d40d5e7bf15b4df486ab4e69c0aaefb79 Mon Sep 17 00:00:00 2001 From: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:04:52 +0900 Subject: [PATCH 008/141] Update old-it-is-dead.md (#42033) Fixing some widowed text --- articles/old-it-is-dead.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/articles/old-it-is-dead.md b/articles/old-it-is-dead.md index 694c581f653..1e2200c2886 100644 --- a/articles/old-it-is-dead.md +++ b/articles/old-it-is-dead.md @@ -135,7 +135,7 @@ If you dismissed AI tooling before late 2025, I'd genuinely encourage you to try *Tested and confirmed working with Kilo Code and Claude. Architecturally compatible with any AI investment your organization has already made. The open source future is already here β€” you can see it and experience is yourself, right now.* - + From c223e1ac4ad6f80452ea1271ef06904951601b7e Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:06:56 -0500 Subject: [PATCH 009/141] Fix typo in AI tooling article (#42031) Corrected a typo in the text about AI tooling and open source. --- articles/old-it-is-dead.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/articles/old-it-is-dead.md b/articles/old-it-is-dead.md index 1e2200c2886..475d3ea3a1c 100644 --- a/articles/old-it-is-dead.md +++ b/articles/old-it-is-dead.md @@ -133,7 +133,7 @@ If you dismissed AI tooling before late 2025, I'd genuinely encourage you to try --- -*Tested and confirmed working with Kilo Code and Claude. Architecturally compatible with any AI investment your organization has already made. The open source future is already here β€” you can see it and experience is yourself, right now.* +*Tested and confirmed working with Kilo Code and Claude. Architecturally compatible with any AI investment your organization has already made. The open source future is already here β€” you can see it and experience it yourself, right now.* From 86872a3d04c48cf4c80cc6eb502615668e3c5b9c Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:17:19 -0500 Subject: [PATCH 010/141] Handbook: Add fleetdm/demo as exception to one-repo policy (#42036) ## Summary - Adds `fleetdm/demo` as a documented exception (#6) to the "Why do we use one repo?" section of the "Why this way?" handbook page. This exception acknowledges that demo environments benefit from a lightweight, standalone repository that can be cloned and run independently. --- Built for [Sam Pfluger](https://fleetdm.slack.com/archives/D0AF8QFBVHB/p1773886197818949) by [Kilo for Slack](https://kilo.ai/features/slack-integration) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- handbook/company/why-this-way.md | 1 + 1 file changed, 1 insertion(+) diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index b23da01c557..4df24288b16 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -130,6 +130,7 @@ The only exceptions are: - _vulnerabilities:_ [`fleetdm/vulnerabilities`](https://github.com/fleetdm/vulnerabilities) - _nvd:_ [`fleetdm/nvd`](https://github.com/fleetdm/nvd) 5. **Terraform modules:** Since Terraform clones the entire repo once per tagged version of a module, we maintain a separate repo for Terraform modules at [fleetdm/fleet-terraform](https://github.com/fleetdm/fleet-terraform) to expedite deployments using `terraform init`. +6. **Demo content:** Since demo environments benefit from a lightweight, standalone repository that can be cloned and run independently, we maintain a separate repo at [`fleetdm/demo`](https://github.com/fleetdm/demo) for demo-related content and configurations. Besides the exceptions above, Fleet does not use any other repositories. Other GitHub repositories in `fleetdm` should be archived and made private. From 079fa76d4db729357e685ad5dee40c0fd54f8094 Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:32:17 +0900 Subject: [PATCH 011/141] Update homepage copy: AI adoption messaging (#42037) ## Summary - Updated homepage hero copy in `website/views/pages/homepage.ejs` to refine the AI adoption messaging - Changed "risky" to "problematic" and "AI-generated changes that can be reviewed safely" to "code, so teams can move faster with confidence" ## Changes **Before:** > Manual Click-Ops in legacy tools make adopting AI risky. Fleet turns device management into AI-generated changes that can be reviewed safely. **After:** > Manual Click-Ops in legacy tools make adopting AI problematic. Fleet turns device management into code, so teams can move faster with confidence. --- Built for [Michael Thomas](https://fleetdm.slack.com/archives/D0AL6RD36GL/p1773886980693869) by [Kilo for Slack](https://kilo.ai/features/slack-integration) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- website/views/pages/homepage.ejs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs index c679a5699e4..c1d6c259f48 100644 --- a/website/views/pages/homepage.ejs +++ b/website/views/pages/homepage.ejs @@ -71,7 +71,7 @@

You can just do things

-

Manual Click-Ops in legacy tools make adopting AI risky. Fleet turns device management into AI-generated changes that can be reviewed safely.

+

Manual Click-Ops in legacy tools make adopting AI problematic. Fleet turns device management into code, so teams can move faster with confidence.

From 027215e6e21a919bd1a681821b9da28e4921ed13 Mon Sep 17 00:00:00 2001 From: Irena Reedy Date: Wed, 18 Mar 2026 19:42:14 -0700 Subject: [PATCH 012/141] Create open-source-software-company.md (#42005) Co-authored-by: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com> --- articles/open-source-software-company.md | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 articles/open-source-software-company.md diff --git a/articles/open-source-software-company.md b/articles/open-source-software-company.md new file mode 100644 index 00000000000..660ae828aa0 --- /dev/null +++ b/articles/open-source-software-company.md @@ -0,0 +1,74 @@ +# Open-source software company closes the Linux gap in device management with Fleet + +A global open-source software company manages Windows, macOS, and Linux at scale. Linux is central to its business, but existing tools didn’t support it with the same depth as macOS and Windows. + +Fleet helps the company adopt Linux as part of a unified endpoint strategy while supporting strict security and audit requirements. + +## At a glance + +* **Industry:** Open-source software + +* **Devices managed:** ~5,100 devices across Windows, macOS, and Linux + +* **Primary requirements:** Self-hosting, GitOps, visibility into their devices + +* **Previous challenge:** Weak Linux support in existing tools + +## The challenge + +Before Fleet, the company used tools such as Jamf and Intune for macOS and Windows. + +That left Linux as a major gap. For an organization built around Linux and open-source software, that disconnect created both technical and operational friction. + +The team needed a platform that could handle Linux seriously, support self-hosting, and align with compliance requirements such as Common Criteria certification and security audits. + +## The evaluation criteria + +The team focused on three requirements: + +1. **Self-hosted deployment** + Maintain control for security and compliance. + +2. **GitOps workflows** + Manage policy and software through code. + +3. **osquery integration** + Collect detailed data for compliance and security monitoring. + +## The solution + +Fleet gave the team a way to manage Windows, macOS, and Linux through one platform. + +The company uses GitOps workflows to automate software management and keep endpoint configuration aligned with security policy. Migration moved quickly, starting with user acceptance testing and progressing to broad rollout within weeks. + +Fleet’s open-source model was a strong fit for the company’s culture and technical standards. + +## The results + +Fleet helped the team close Linux gaps and simplify management across operating systems. + +* **Fast rollout:** From testing to broad rollout in just a matter of weeks + +* **Better Linux coverage:** Linux devices now align with macOS and Windows management. + +* **Simpler operations:** One platform reduces administrative complexity for a global workforce. + +## Why they recommend Fleet + +Fleet lets the team manage Linux, macOS, and Windows in one place, while staying aligned with its open-source and compliance requirements. + +## About Fleet + +Fleet is the single endpoint management platform for macOS, iOS, Android, Windows, Linux, ChromeOS, and cloud infrastructure. Trusted by over 1,300 organizations, Fleet empowers IT and security teams to accelerate productivity, build verifiable trust, and optimize costs. + +By bringing infrastructure-as-code (IaC) practices to device management, Fleet ensures endpoints remain secure and operational, freeing engineering teams to focus on strategic initiatives. + +Fleet offers total deployment flexibility: on-premises, air-gapped, container-native (Docker and Kubernetes), or cloud-agnostic (AWS, Azure, GCP, DigitalOcean). Organizations can also choose fully managed SaaS via Fleet Cloud, ensuring complete control over data residency and legal jurisdiction. + + + + + + + + From a5d4c367b83968428e22970fb6d8cb4118cbc149 Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:45:33 +0900 Subject: [PATCH 013/141] Move 'Open by design' section below 'Moving to Fleet' on homepage (#42038) ## Summary - Reorders homepage sections so that "Open by design" appears immediately after the "Moving to Fleet" comparison section, instead of before the testimonials carousel. - Only the section order is changed; no content modifications were made. ## Changes - `website/views/pages/homepage.ejs`: Moved the "Open by design" text block and "three-column-features" block from between "See reality clearly" and "What people are saying" to after the "Moving to Fleet" comparison table and its CTA buttons. Built for [Michael Thomas](https://fleetdm.slack.com/archives/D0AL6RD36GL/p1773887593802709) by [Kilo for Slack](https://kilo.ai/features/slack-integration) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- website/views/pages/homepage.ejs | 58 ++++++++++++++++---------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/website/views/pages/homepage.ejs b/website/views/pages/homepage.ejs index c1d6c259f48..803ee2402f7 100644 --- a/website/views/pages/homepage.ejs +++ b/website/views/pages/homepage.ejs @@ -156,35 +156,6 @@
-
-

Open by design

-

At Fleet, everyone can contribute. We are dedicated to making tools that are easy for everyone to understand.

-
- -
-
- -
- transparency -
"Why is this running on my computer?"
-

Let end users see the source code for exactly how they are being monitored, and set clear expectations about what is and isn’t acceptable use of work computers.

-
- -
- Free as in free -
Free as in free
-

The free version of Fleet will always be free. Fleet is independently backed and actively maintained with the help of many amazing contributors.

-
- -
- An open book -
Open source
-

All of Fleet's source code is public, and our company handbook is public and open to the world. You can read about the history of Fleet and osquery and our commitment to improving the product.

-
- -
-
-

What people are saying

@@ -1474,6 +1445,35 @@ Get a demo
+ +
+

Open by design

+

At Fleet, everyone can contribute. We are dedicated to making tools that are easy for everyone to understand.

+
+ +
+
+ +
+ transparency +
"Why is this running on my computer?"
+

Let end users see the source code for exactly how they are being monitored, and set clear expectations about what is and isn't acceptable use of work computers.

+
+ +
+ Free as in free +
Free as in free
+

The free version of Fleet will always be free. Fleet is independently backed and actively maintained with the help of many amazing contributors.

+
+ +
+ An open book +
Open source
+

All of Fleet's source code is public, and our company handbook is public and open to the world. You can read about the history of Fleet and osquery and our commitment to improving the product.

+
+ +
+
<%/* Bottom gradient */%> From 02473418dd4c3c610327cdad3bb035a9a91d6864 Mon Sep 17 00:00:00 2001 From: Irena Reedy Date: Wed, 18 Mar 2026 19:50:01 -0700 Subject: [PATCH 014/141] Create consumer-electronics.md (#42001) Co-authored-by: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com> --- articles/consumer-electronics.md | 74 ++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 articles/consumer-electronics.md diff --git a/articles/consumer-electronics.md b/articles/consumer-electronics.md new file mode 100644 index 00000000000..7cb5d97bf38 --- /dev/null +++ b/articles/consumer-electronics.md @@ -0,0 +1,74 @@ +# Consumer electronics company simplifies cross-platform management with Fleet + +A consumer electronics company supports employees and contractors across a global business. Its environment includes macOS, Windows, and Linux devices used by both business teams and engineers. + +Existing tools created friction and left Linux unmanaged. Fleet helps the company manage all devices in one place. + +## At a glance + +* **Industry:** Consumer electronics and audio technology + +* **Devices managed:** ~3,200-3,400 devices + +* **Primary requirements:** GitOps workflows, visibility into all devices, unified management + +* **Previous challenge:** Jamf and Intune created bottlenecks with weak Linux coverage + +## The challenge + +Before Fleet, the company relied on Jamf and Intune. + +Those tools created friction in different ways. Jamf involved certificate and profile complexity, and Intune was slow to return data and lacked some remote management features the team needed. Linux devices remained a major blind spot. + +The team wanted one platform that could support all major operating systems and reduce the need to switch between consoles. + +## The evaluation criteria + +The team focused on three capabilities: + +1. **GitOps workflows** + Manage devices through code and version control. + +2. **osquery visibility** + Collect deep, real-time endpoint data. + +3. **Unified management** + Support macOS, Windows, and Linux in one system. + +## The solution + +Fleet gave the team one platform to track devices, enforce compliance, and query data in real time. + +The company integrated Fleet with internal inventory systems and GitHub to automate compliance tracking and keep asset records up to date. Linux enrollment is being rolled out in phases, with a self-service model designed to support adoption without disrupting engineering workflows. + +The open development model also helped build trust with technical users who want visibility into how the product evolves. + +## The results + +Fleet helps the team reduce complexity and improve response time. + +* **Improved Linux coverage:** Linux devices are now managed for the first time. + +* **Faster access to device data:** Teams can act on compliance and security issues faster. + +* **Tool consolidation:** Fewer separate management systems are needed across operating systems. + +## Why they recommend Fleet + +For this company, the biggest benefit is operational simplicity. Fleet helps their team manage more devices with fewer tools and faster access to the data they need. + +## About Fleet + +Fleet is the single endpoint management platform for macOS, iOS, Android, Windows, Linux, ChromeOS, and cloud infrastructure. Trusted by over 1,300 organizations, Fleet empowers IT and security teams to accelerate productivity, build verifiable trust, and optimize costs. + +By bringing infrastructure-as-code (IaC) practices to device management, Fleet ensures endpoints remain secure and operational, freeing engineering teams to focus on strategic initiatives. + +Fleet offers total deployment flexibility: on-premises, air-gapped, container-native (Docker and Kubernetes), or cloud-agnostic (AWS, Azure, GCP, DigitalOcean). Organizations can also choose fully managed SaaS via Fleet Cloud, ensuring complete control over data residency and legal jurisdiction. + + + + + + + + From dd92b094ae75cc7aa1ab812be7b5649e7f240b88 Mon Sep 17 00:00:00 2001 From: Michael Buck Date: Wed, 18 Mar 2026 22:02:10 -0500 Subject: [PATCH 015/141] added middle initial (#42024) my first pull request working locally. Co-authored-by: Ashish Kuthiala <53918208+akuthiala@users.noreply.github.com> --- handbook/marketing/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/marketing/README.md b/handbook/marketing/README.md index 6cc078eb739..44dc40c4346 100644 --- a/handbook/marketing/README.md +++ b/handbook/marketing/README.md @@ -10,7 +10,7 @@ This handbook page details processes specific to working [with](#contact-us) and | Content Specialist | [Irena Reedy](https://www.linkedin.com/in/irena-reedy-520ab9354/) _([@irenareedy](https://github.com/irenareedy))_ | Head of Demand Generation | [John Jeremiah](https://www.linkedin.com/in/johnjeremiah/) _([@johnjeremiah](https://github.com/johnjeremiah))_ | Product Education Manager | [Brock Walters](https://www.linkedin.com/in/brock-walters-247a2990/) _([@nonpunctual](https://github.com/nonpunctual))_ -| Marketing Campaign Manager | [Michael Buck](https://www.linkedin.com/in/michael-buck-chi312/ ) _([@mb-chigoose312](https://github.com/mb-chigoose312))_ +| Marketing Campaign Manager | [Michael D Buck](https://www.linkedin.com/in/michael-buck-chi312/ ) _([@mb-chigoose312](https://github.com/mb-chigoose312))_ ## Contact us From a82458395730fd705ec7315e8072fc9535f13d57 Mon Sep 17 00:00:00 2001 From: Brock Walters <153771548+nonpunctual@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:20:50 -0400 Subject: [PATCH 016/141] Update product-education.md (#42028) Co-authored-by: Ashish Kuthiala <53918208+akuthiala@users.noreply.github.com> --- handbook/marketing/product-education.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/handbook/marketing/product-education.md b/handbook/marketing/product-education.md index ae4f6fe641f..38bc5883837 100644 --- a/handbook/marketing/product-education.md +++ b/handbook/marketing/product-education.md @@ -6,17 +6,21 @@ ## Fleet training course certification standards -*A Fleetie seeking certification to deliver an offically sanctioned Fleet training course must:* +*A Fleetie seeking certification to deliver an offically-sanctioned Fleet training course must:* ``` -1. Take the course they intend to be certified to deliver. +1. Sign up for & successfully complete the course they intend to be certified to deliver. + 2. Shadow a 2nd course: + a. Acknowledge & read any intstructor guides that exist for the course. + b. Attend a course delivered by a certified instructor. + b. Help students with issues or answer student questions. + c. Demonstrate an understanding of how an instructor delivers the course. + +3. Teach any part of a 3rd course: a. Attend a course delivered by a certified instructor. - b. Help students with issues. - c. Understand what the instructor is doing. -3. Teach part of a 3rd course: - a. Attend a course delivered by a certified instructor. - b. Deliver lessons. + b. Deliver course lessons. + 4. Teach a course. a. Ideally with a certified instructor "in the room" (in-person or virtually). ``` @@ -26,4 +30,4 @@ If these steps are completed for any current online or in-person training, the F >This is a clear set of instructions, but it is also a system of honor, kind of like getting knighted. Once made a knight, you can knight other knights ("System admins" also tend to follow this pattern.) Use this power wisely. We want students to have great experiences. Great Experiences in classes are delvered by qualified instructors. - + From a41b0dbc5bcf0cb9b4426beb589ed8e3dcbeadf4 Mon Sep 17 00:00:00 2001 From: Irena Reedy Date: Wed, 18 Mar 2026 20:22:19 -0700 Subject: [PATCH 017/141] Create data-platform.md (#41995) Co-authored-by: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com> --- articles/data-platform.md | 74 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 articles/data-platform.md diff --git a/articles/data-platform.md b/articles/data-platform.md new file mode 100644 index 00000000000..34fa9e68d7f --- /dev/null +++ b/articles/data-platform.md @@ -0,0 +1,74 @@ +# Data platform company cuts $5–6M in hardware costs with API-driven device management + +A global data platform company supports a large workforce across macOS, Windows, Linux, iOS, and Android. With device count matching its workforce, the team needed a scalable way to manage hardware, security, and compliance. + +Fleet helps the company manage devices through APIs and stream endpoint telemetry directly into its own data platform. + +## At a glance + +* **Industry:** Data cloud technology + +* **Devices managed:** ~10,000-11,000 devices + +* **Primary requirements:** API and GitOps support, multi-OS management, real-time data streaming + +* **Previous challenge:** Legacy tools were expensive and did not support Linux and BYOD well + +## The challenge + +Before Fleet, the company relied on tools that worked well for macOS but did not support Linux, Android, and BYOD with the same depth. + +The team also wanted to move away from expensive licensing models and manual UI-driven workflows. Linux devices and BYOD systems were especially hard to manage, which limited visibility across the environment. + +## The evaluation criteria + +The team focused on three priorities: + +1. **API and GitOps support** + Remove manual operations and manage workflows through code. + +2. **Multi-OS management** + Support macOS, iOS, Android, Windows, and Linux from one platform. + +3. **Real-time data streaming** + Send endpoint telemetry directly into the company’s internal data platform. + +## The solution + +Fleet provided the team with a platform that aligns with its API-first engineering model. + +The company uses Fleet for asynchronous file verification and streams endpoint telemetry directly into its internal data environment. This allows security teams to query, model, and act on endpoint data within minutes. + +Fleet inventory data also helps the company make better hardware decisions, including identifying overprovisioned devices and improving refresh planning. + +## The results + +Fleet improved both operational efficiency and cost control. + +* **Major hardware savings:** Fleet data helped identify opportunities that saved an estimated $5-6 million in hardware costs. + +* **Better multi-OS visibility:** Linux and BYOD systems now fit into the broader management strategy. + +* **Faster security analysis:** Streaming telemetry shortens the gap between data collection and response. + +## Why they recommend Fleet + +For this company, the biggest benefit is API-driven efficiency. + +Fleet gives the team one platform for automation, cross-platform management, and real-time endpoint data. + +## About Fleet + +Fleet is the single endpoint management platform for macOS, iOS, Android, Windows, Linux, ChromeOS, and cloud infrastructure. Trusted by over 1,300 organizations, Fleet empowers IT and security teams to accelerate productivity, build verifiable trust, and optimize costs. + +By bringing infrastructure-as-code (IaC) practices to device management, Fleet ensures endpoints remain secure and operational, freeing engineering teams to focus on strategic initiatives. + +Fleet offers total deployment flexibility: on-premises, air-gapped, container-native (Docker and Kubernetes), or cloud-agnostic (AWS, Azure, GCP, DigitalOcean). Organizations can also choose fully managed SaaS via Fleet Cloud, ensuring complete control over data residency and legal jurisdiction. + + + + + + + + From b87bf3b8268d748c3a0aff9aaab65ef8859ae01b Mon Sep 17 00:00:00 2001 From: Irena Reedy Date: Wed, 18 Mar 2026 20:34:09 -0700 Subject: [PATCH 018/141] Create computational-research-company.md (#41989) Co-authored-by: Ashish Kuthiala <53918208+akuthiala@users.noreply.github.com> Co-authored-by: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com> --- articles/computational-research-company.md | 74 ++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 articles/computational-research-company.md diff --git a/articles/computational-research-company.md b/articles/computational-research-company.md new file mode 100644 index 00000000000..0ca80491cc2 --- /dev/null +++ b/articles/computational-research-company.md @@ -0,0 +1,74 @@ +# Computational research company unifies endpoint management with Fleet + +A computational research company develops software that accelerates drug discovery and materials science. Its teams rely on macOS, Linux, and Windows devices across a highly technical environment. + +Before Fleet, Linux desktops and servers were less visible and harder to manage. Fleet helps the company bring those systems into a single platform with better automation and stronger policy enforcement. + +## At a glance + +* **Industry:** Healthcare technology and computational research + +* **Devices managed:** ~1,553 devices across macOS, Linux, and Windows + +* **Primary requirements:** Unified visibility, security enforcement, GitOps, and osquery support + +* **Previous challenge:** Linux systems lacked consistent management and visibility + +## The challenge + +Before Fleet, the company relied on multiple tools across operating systems. + +Workspace ONE did not meet expectations for support and communication. Intune lacked some Windows capabilities the team needed, and Jamf did not provide sufficient depth for a mixed environment with a large Linux footprint. + +Linux desktops and servers were the biggest gap. The team needed a way to bring those systems into a more consistent security and compliance program. + +## The evaluation criteria + +The team focused on three priorities: + +1. **Unified visibility** + Manage macOS, Windows, and Linux from one place. + +2. **Security and compliance enforcement** + Automatically apply policies and reduce manual work. + +3. **GitOps and osquery support** + Manage configuration through code and use SQL-based telemetry for deeper visibility. + +## The solution + +Fleet gave the team a single system for policy management, device visibility, and automation. + +The company uses Fleet to run scripts on Linux, map device users via SCIM, and schedule updates that reduce disruption for scientists and technical staff. Fleet also replaced parts of its previous automation stack, which reduced complexity. + +The open-source model was a strong fit because it gave the team direct visibility into how the platform works and how features evolve over time. + +## The results + +Fleet helped the company reduce silos and improve device oversight across operating systems. + +* **Improved Linux visibility:** Linux devices that were previously less managed are now part of a unified workflow. + +* **Stronger vulnerability response:** Real-time telemetry and policy enforcement help the team respond faster. + +* **Less tool sprawl:** Fleet replaced separate workflows and helped centralize management. + +## Why they recommend Fleet + +For this company, the biggest benefit is unified management. Fleet gives the team one place to manage macOS, Linux, and Windows with greater transparency, automation, and reduced operational overhead. + +## About Fleet + +Fleet is the single endpoint management platform for macOS, iOS, Android, Windows, Linux, ChromeOS, and cloud infrastructure. Trusted by over 1,300 organizations, Fleet empowers IT and security teams to accelerate productivity, build verifiable trust, and optimize costs. + +By bringing infrastructure-as-code (IaC) practices to device management, Fleet ensures endpoints remain secure and operational, freeing engineering teams to focus on strategic initiatives. + +Fleet offers total deployment flexibility: on-premises, air-gapped, container-native (Docker and Kubernetes), or cloud-agnostic (AWS, Azure, GCP, DigitalOcean). Organizations can also choose fully managed SaaS via Fleet Cloud, ensuring complete control over data residency and legal jurisdiction. + + + + + + + + From 76e9e2a81bb1f3535330caea374fb9adf7b4a8f4 Mon Sep 17 00:00:00 2001 From: Irena Reedy Date: Wed, 18 Mar 2026 20:34:44 -0700 Subject: [PATCH 019/141] Create it-service-company.md (#41994) Co-authored-by: Mike Thomas <78363703+mike-j-thomas@users.noreply.github.com> --- articles/it-service-company.md | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 articles/it-service-company.md diff --git a/articles/it-service-company.md b/articles/it-service-company.md new file mode 100644 index 00000000000..c35bbfb152a --- /dev/null +++ b/articles/it-service-company.md @@ -0,0 +1,72 @@ +# IT services company builds zero-touch workflows with Fleet + +This IT services company provides endpoint management and security solutions for enterprise customers. As it expands its own environment, the team wants device management that is secure by default and easy to automate. + +Fleet helps the company build zero-touch deployment workflows and manage device state through code. + +## At a glance + +* **Industry:** IT services and endpoint management + +* **Devices managed:** 100+ Windows devices, expanding to macOS and Linux + +* **Primary requirements:** Zero-touch deployment, GitOps workflows, osquery visibility + +* **Previous challenge:** Legacy tools could not support secure, automated deployments at scale + +## The challenge + +Before Fleet, the company relied on legacy tools that were not built for zero-touch, secure laptop deployment. + +The team needed better visibility into device state and a more automated way to manage credentials, policies, and user transitions. Existing tools relied on manual workflows, created friction, and could not support a secure-by-default model at scale. + +## The evaluation criteria + +The team focused on three capabilities: + +1. **Zero-touch deployment** + Automate device setup from the moment a machine is unboxed. + +2. **GitOps workflows** + Manage device state programmatically rather than through manual UI actions. + +3. **osquery integration** + Use deep system-level data to drive security workflows. + +## The solution + +Fleet gave the team a platform for automated deployment, identity-aware workflows, and real-time visibility. + +The company used the Fleet API to rotate Recovery Lock passwords, manage hidden admin accounts, and respond to identity changes without manual intervention. Integrations with Okta also helped automatically remove deactivated users, reducing the risk of leftover access on managed devices. + +The open-source model was important because it allowed the team to inspect the code and verify behavior directly. + +## The results + +Fleet gave the team a stronger foundation for secure deployment and lifecycle management. + +* **Better zero-touch workflows:** New devices can be set up with more consistency and less manual work. + +* **Faster security response:** Identity and device changes can trigger action quickly. + +* **Simpler management:** The team can centralize security policies across operating systems. + +## Why they recommend Fleet + +For this company, the biggest benefit is zero-touch visibility. Fleet helps the team build automated workflows that are more secure, more scalable, and easier to verify. + +## About Fleet + +Fleet is the single endpoint management platform for macOS, iOS, Android, Windows, Linux, ChromeOS, and cloud infrastructure. Trusted by over 1,300 organizations, Fleet empowers IT and security teams to accelerate productivity, build verifiable trust, and optimize costs. + +By bringing infrastructure-as-code (IaC) practices to device management, Fleet ensures endpoints remain secure and operational, freeing engineering teams to focus on strategic initiatives. + +Fleet offers total deployment flexibility: on-premises, air-gapped, container-native (Docker and Kubernetes), or cloud-agnostic (AWS, Azure, GCP, DigitalOcean). Organizations can also choose fully managed SaaS via Fleet Cloud, ensuring complete control over data residency and legal jurisdiction. + + + + + + + + From 0e103ed63b085aa43d5dee1419aae02f6fbdb7c7 Mon Sep 17 00:00:00 2001 From: Sam Pfluger <108141731+Sampfluger88@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:55:40 -0500 Subject: [PATCH 020/141] Clarify repository usage by removing demo content note (#42040) Removed mention of demo content repository for clarity. --- handbook/company/why-this-way.md | 1 - 1 file changed, 1 deletion(-) diff --git a/handbook/company/why-this-way.md b/handbook/company/why-this-way.md index 4df24288b16..b23da01c557 100644 --- a/handbook/company/why-this-way.md +++ b/handbook/company/why-this-way.md @@ -130,7 +130,6 @@ The only exceptions are: - _vulnerabilities:_ [`fleetdm/vulnerabilities`](https://github.com/fleetdm/vulnerabilities) - _nvd:_ [`fleetdm/nvd`](https://github.com/fleetdm/nvd) 5. **Terraform modules:** Since Terraform clones the entire repo once per tagged version of a module, we maintain a separate repo for Terraform modules at [fleetdm/fleet-terraform](https://github.com/fleetdm/fleet-terraform) to expedite deployments using `terraform init`. -6. **Demo content:** Since demo environments benefit from a lightweight, standalone repository that can be cloned and run independently, we maintain a separate repo at [`fleetdm/demo`](https://github.com/fleetdm/demo) for demo-related content and configurations. Besides the exceptions above, Fleet does not use any other repositories. Other GitHub repositories in `fleetdm` should be archived and made private. From a3fd40a1e78b3a1c552f5e3815d37164f4100c5c Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:27:05 +0900 Subject: [PATCH 021/141] Add missing anonymous case study cards to customers page (#42041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds 7 missing anonymous case study cards to the `/customers` page (`website/views/pages/testimonials.ejs`) - New cards added (in alphabetical order among existing cards): - **Computational research company** β€” `/case-study/computational-research-company` - **Consumer electronics** β€” `/case-study/consumer-electronics` - **Data platform** β€” `/case-study/data-platform` - **Financial services company** β€” `/case-study/financial-services-company-1` - **National research lab** β€” `/case-study/national-research-lab` - **Open-source software company** β€” `/case-study/open-source-software-company` - **Open-source technology company** β€” `/case-study/open-source-technology-company` - Each card follows the existing anonymous card markup pattern with company name, caption (from the case study title), and "Read their story" link - 11 of the 18 requested case studies already had cards on the page and were left unchanged Built for [Michael Thomas](https://fleetdm.slack.com/archives/D0AL6RD36GL/p1773892493296179) by [Kilo for Slack](https://kilo.ai/features/slack-integration) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- website/views/pages/testimonials.ejs | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/website/views/pages/testimonials.ejs b/website/views/pages/testimonials.ejs index 7c65f304a57..d34ca579c5a 100644 --- a/website/views/pages/testimonials.ejs +++ b/website/views/pages/testimonials.ejs @@ -183,6 +183,16 @@

Communications platform unifies device management across 3,000 devices.

Read their story +
+

Computational research company

+

Computational research company unifies endpoint management with Fleet.

+ Read their story +
+
+

Consumer electronics

+

Consumer electronics company simplifies cross-platform management with Fleet.

+ Read their story +

Communications platform

A cybersecurity company improves device visibility.

@@ -193,6 +203,11 @@

Cybersecurity company improves Linux management with Fleet, replacing legacy tools.

Read their story
+
+

Data platform

+

Data platform company cuts $5–6M in hardware costs with API-driven device management.

+ Read their story +

Digital bank

Digital bank strengthens security and compliance with Fleet.

@@ -218,6 +233,11 @@

Financial services company migrates to Fleet for MDM and next-gen change management

Read their story
+
+

Financial services company

+

Financial services company reduces tool sprawl with Fleet.

+ Read their story +

Financial services platform

Financial services platform manages 6,000+ hosts with continuous compliance visibility.

@@ -323,6 +343,11 @@

A research organization uses Fleet to automate Linux patching and improve device visibility.

Read their story
+
+

National research lab

+

National research lab scales host visibility with Fleet.

+ Read their story +

Online gaming platform

Large gaming company enhances server observability with Fleet.

@@ -338,6 +363,16 @@

Open-source organization manages 1,556 devices with real-time compliance.

Read their story
+
+

Open-source software company

+

Open-source software company closes the Linux gap in device management with Fleet.

+ Read their story +
+
+

Open-source technology company

+

Open-source technology company scales endpoint management with Fleet.

+ Read their story +

Robotics company

Robotics company unifies Mac, Windows, Linux, and Android devices.

From 674b3c4d97a2076df039a281e0b6392d5848b630 Mon Sep 17 00:00:00 2001 From: Gray Williams Date: Thu, 19 Mar 2026 08:08:13 +0000 Subject: [PATCH 022/141] Update Windows host wipe instructions for doWipe (#41832) Clarify the default use of the doWipeProtected command for Windows hosts, and mention the availability of the doWipe command via the API. --- articles/lock-wipe-hosts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/articles/lock-wipe-hosts.md b/articles/lock-wipe-hosts.md index 9a9a15c1e3c..3870a2f9a9d 100644 --- a/articles/lock-wipe-hosts.md +++ b/articles/lock-wipe-hosts.md @@ -55,7 +55,7 @@ When wiping and re-installing the operating system (OS) on a host, delete the ho If you're gifting a company-owned macOS host or you want to prevent the host from automatically re-enrolling to Fleet for some other reason, first release the host from Apple Business Manager (ABM) and then delete the host in Fleet. -For Windows hosts, Fleet uses the [doWipeProtected](https://learn.microsoft.com/en-us/windows/client-management/mdm/remotewipe-csp#dowipeprotected) command. According to Microsoft, this leaves the host [unable to boot](https://learn.microsoft.com/en-us/windows/client-management/mdm/remotewipe-csp#:~:text=In%20some%20device%20configurations%2C%20this%20command%20may%20leave%20the%20device%20unable%20to%20boot.). +For Windows hosts, Fleet uses the [doWipeProtected](https://learn.microsoft.com/en-us/windows/client-management/mdm/remotewipe-csp#dowipeprotected) command by default. According to Microsoft, this leaves the host [unable to boot](https://learn.microsoft.com/en-us/windows/client-management/mdm/remotewipe-csp#:~:text=In%20some%20device%20configurations%2C%20this%20command%20may%20leave%20the%20device%20unable%20to%20boot.). However, it is possible to use the [doWipe command via the API](https://fleetdm.com/docs/rest-api/rest-api#parameters57). ## Unlock a host From 9d913d766dfca9bd80ca64d8fb97bb06f535b721 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Thu, 19 Mar 2026 06:44:15 -0400 Subject: [PATCH 023/141] Fleet UI: Unreleased bug fixes for policy automations filtering (#41991) --- .../ManagePoliciesPage/ManagePoliciesPage.tsx | 214 +++++++++++------- .../policies/ManagePoliciesPage/_styles.scss | 2 +- .../PoliciesPaginatedList.tsx | 43 +++- frontend/services/entities/global_policies.ts | 24 +- frontend/services/entities/team_policies.ts | 20 +- 5 files changed, 201 insertions(+), 102 deletions(-) diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index 13c37ca4006..2f4b1e3c4c9 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -34,12 +34,14 @@ import { TooltipContent } from "interfaces/dropdownOption"; import configAPI from "services/entities/config"; import globalPoliciesAPI, { + GlobalPoliciesAutomationType, IPoliciesCountQueryKey, IPoliciesQueryKey, } from "services/entities/global_policies"; import teamPoliciesAPI, { ITeamPoliciesCountQueryKey, ITeamPoliciesQueryKey, + AutomationType, } from "services/entities/team_policies"; import teamsAPI, { ILoadTeamResponse } from "services/entities/teams"; @@ -88,6 +90,7 @@ interface IManagePoliciesPageProps { order_key?: string; order_direction?: "asc" | "desc"; page?: string; + automation_type?: AutomationType; }; search: string; }; @@ -104,6 +107,16 @@ const [ "Could not update policy automations.", ]; +const AUTOMATION_TYPES: AutomationType[] = [ + "software", + "scripts", + "calendar", + "conditional_access", + "other", +]; + +const GLOBAL_AUTOMATION_TYPES: GlobalPoliciesAutomationType[] = ["other"]; + const baseClass = "manage-policies-page"; const ManagePolicyPage = ({ @@ -194,6 +207,21 @@ const ManagePolicyPage = ({ DEFAULT_SORT_DIRECTION)(); const page = queryParams && queryParams.page ? parseInt(queryParams?.page, 10) : 0; + const initialAutomationFilter = (() => { + const automationQueryParam = queryParams.automation_type; + + if (!automationQueryParam) { + return null; + } + + const validValues = isAllTeamsSelected + ? GLOBAL_AUTOMATION_TYPES + : AUTOMATION_TYPES; + + return (validValues as string[]).includes(automationQueryParam) + ? automationQueryParam + : null; + })(); // Needs update on location change or table state might not match URL const [searchQuery, setSearchQuery] = useState(initialSearchQuery); @@ -205,16 +233,9 @@ const ManagePolicyPage = ({ const [sortDirection, setSortDirection] = useState< "asc" | "desc" | undefined >(initialSortDirection); - const [automationFilter, setAutomationFilter] = useState(null); - - // Maps frontend dropdown values to backend automation_type query param values - const AUTOMATION_FILTER_TO_API: Record = { - install_software: "software", - run_script: "scripts", - calendar_events: "calendar", - conditional_access: "conditional_access", - other_workflows: "other", - }; + const [automationFilter, setAutomationFilter] = useState< + AutomationType | GlobalPoliciesAutomationType | null + >(initialAutomationFilter); useEffect(() => { setLastEditedQueryPlatform(null); @@ -227,12 +248,14 @@ const ManagePolicyPage = ({ setSearchQuery(initialSearchQuery); setSortHeader(initialSortHeader); setSortDirection(initialSortDirection); + setAutomationFilter(initialAutomationFilter); }, [ location, isRouteOk, initialSearchQuery, initialSortHeader, initialSortDirection, + initialAutomationFilter, ]); useEffect(() => { @@ -271,6 +294,7 @@ const ManagePolicyPage = ({ query: searchQuery, orderDirection: sortDirection, orderKey: sortHeader, + automationType: automationFilter as GlobalPoliciesAutomationType, }, ], ({ queryKey }) => { @@ -292,6 +316,7 @@ const ManagePolicyPage = ({ { scope: "policiesCount", query: !isAllTeamsSelected ? "" : searchQuery, + automationType: automationFilter as GlobalPoliciesAutomationType, }, ], ({ queryKey }) => globalPoliciesAPI.getCount(queryKey[0]), @@ -327,9 +352,7 @@ const ManagePolicyPage = ({ teamId: teamIdForApi || 0, // no teams does inherit mergeInherited: true, - automationType: automationFilter - ? AUTOMATION_FILTER_TO_API[automationFilter] - : undefined, + automationType: automationFilter as AutomationType, }, ], ({ queryKey }) => { @@ -357,6 +380,7 @@ const ManagePolicyPage = ({ query: searchQuery, teamId: teamIdForApi || 0, // TODO: Fix number/undefined type mergeInherited: true, + automationType: automationFilter as AutomationType, }, ], ({ queryKey }) => teamPoliciesAPI.getCount(queryKey[0]), @@ -929,6 +953,21 @@ const ManagePolicyPage = ({ toggleDeletePoliciesModal, ]); + const onChangeAutomationFilter = (val: SingleValue) => { + const automationType = val?.value; + + const locationPath = getNextLocationPath({ + pathPrefix: PATHS.MANAGE_POLICIES, + queryParams: { + ...queryParams, + page: "0", + automation_type: automationType === "all" ? undefined : automationType, + }, + }); + + router?.push(locationPath); + }; + const policiesErrors = !isAllTeamsSelected ? teamPoliciesError : globalPoliciesError; @@ -974,7 +1013,7 @@ const ManagePolicyPage = ({ count?: number, policies?: IPolicyStats[] ) => { - // Hide count if fetching count || there are errors OR there are no policy results with no a search filter + // Hide count if fetching count || there are errors OR there are no policy results with no filters (search or automation dropdown) const isFetchingCount = !isAllTeamsSelected ? isFetchingTeamCountMergeInherited : isFetchingGlobalCount; @@ -982,7 +1021,7 @@ const ManagePolicyPage = ({ const hide = isFetchingCount || policiesErrors || - (!policyResults && searchQuery === ""); + (!policyResults && searchQuery === "" && !automationFilter); if (hide) { return null; @@ -1009,61 +1048,80 @@ const ManagePolicyPage = ({ ); }; - // Client-side filtering is still needed for global policies since the - // global policies endpoint does not support automation_type. Team policies - // use the server-side automation_type query param instead. - const filterGlobalPoliciesByAutomation = ( - policies: IPolicyStats[] - ): IPolicyStats[] => { - if (!automationFilter) return policies; - return policies.filter((p) => { - switch (automationFilter) { - case "install_software": - return !!p.install_software; - case "run_script": - return !!p.run_script; - case "calendar_events": - return p.calendar_events_enabled; - case "conditional_access": - return p.conditional_access_enabled; - case "other_workflows": - return currentAutomatedPolicies.includes(p.id); - default: - return true; - } - }); - }; - const automationFilterOptions: CustomOptionType[] = [ - { label: "All automations", value: "all" }, - { label: "Software", value: "install_software" }, - { label: "Scripts", value: "run_script" }, - { label: "Calendar", value: "calendar_events" }, - { label: "Conditional access", value: "conditional_access" }, - { label: "Other", value: "other_workflows" }, + { + label: "All policies", + value: "all", + helpText: "All policies added to Fleet.", + }, + { + label: "Software", + value: "software", + helpText: "Policies with software automation enabled.", + }, + { + label: "Scripts", + value: "scripts", + helpText: "Policies with script automation enabled.", + }, + { + label: "Calendar", + value: "calendar", + helpText: "Policies with calendar event automation enabled.", + }, + { + label: "Conditional access", + value: "conditional_access", + helpText: "Policies with conditional access automation enabled.", + }, + { + label: "Other", + value: "other", + helpText: "Policies with other automation enabled.", + }, ]; + const allPoliciesOption = automationFilterOptions[0]; // value: "all" + + const getSelectedFilterOption = () => { + if (!automationFilter) { + return allPoliciesOption; // Default to all policies option + } + return automationFilterOptions.find( + (opt) => opt.value === automationFilter + ); + }; + const renderAutomationFilter = isPremiumTier - ? () => ( - ) => { - const newFilter = - val?.value && val.value !== "all" ? val.value : null; - setAutomationFilter(newFilter); - // Reset to first page when filter changes - const locationPath = getNextLocationPath({ - pathPrefix: PATHS.MANAGE_POLICIES, - queryParams: { ...queryParams, page: "0" }, - }); - router?.push(locationPath); - }} - placeholder="Filter by automation" - options={automationFilterOptions} - variant="table-filter" - /> - ) + ? () => { + // Hide dropdown if there are errors OR there are no policy results with no filters (search or automation dropdown) + const hide = + policiesErrors || + (!policyResults && searchQuery === "" && !automationFilter); + + if (hide) { + return null; + } + + // No team ID = All fleets β†’ only show "all" and "other" options + const optionsForTeam = teamIdForApi + ? automationFilterOptions + : automationFilterOptions.filter((opt) => + ["all", "other"].includes(opt.value as string) + ); + + return ( + + ); + } : undefined; const renderMainTable = () => { @@ -1076,15 +1134,9 @@ const ManagePolicyPage = ({ if (globalPoliciesError) { return ; } - const filteredGlobalPolicies = filterGlobalPoliciesByAutomation( - globalPolicies || [] - ); - const filteredGlobalCount = automationFilter - ? filteredGlobalPolicies.length - : globalPoliciesCount || 0; return ( renderPoliciesCountAndLastUpdated( - filteredGlobalCount, - filteredGlobalPolicies + globalPoliciesCount, + globalPolicies ) } - count={filteredGlobalCount} + count={globalPoliciesCount || 0} searchQuery={searchQuery} sortHeader={sortHeader} sortDirection={sortDirection} @@ -1115,11 +1167,7 @@ const ManagePolicyPage = ({ return ; } const displayedTeamPolicies = teamPolicies || []; - // When a filter is active, use the returned array length as the count - // since the count endpoint doesn't support automation_type yet. - const displayedTeamCount = automationFilter - ? displayedTeamPolicies.length - : teamPoliciesCountMergeInherited || 0; + return (
renderPoliciesCountAndLastUpdated( - displayedTeamCount, + teamPoliciesCountMergeInherited, displayedTeamPolicies ) } isPremiumTier={isPremiumTier} - count={displayedTeamCount} + count={teamPoliciesCountMergeInherited || 0} searchQuery={searchQuery} sortHeader={sortHeader} sortDirection={sortDirection} diff --git a/frontend/pages/policies/ManagePoliciesPage/_styles.scss b/frontend/pages/policies/ManagePoliciesPage/_styles.scss index 576b9290d12..3c34b40e154 100644 --- a/frontend/pages/policies/ManagePoliciesPage/_styles.scss +++ b/frontend/pages/policies/ManagePoliciesPage/_styles.scss @@ -20,7 +20,7 @@ } &__filter-automation-dropdown { - min-width: 200px; + min-width: 277px; } &__manage-automations-wrapper { diff --git a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesPaginatedList/PoliciesPaginatedList.tsx b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesPaginatedList/PoliciesPaginatedList.tsx index 8313f67c5e9..c6f65003583 100644 --- a/frontend/pages/policies/ManagePoliciesPage/components/PoliciesPaginatedList/PoliciesPaginatedList.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/components/PoliciesPaginatedList/PoliciesPaginatedList.tsx @@ -21,7 +21,9 @@ import teamPoliciesAPI, { IPoliciesApiParams, IPoliciesCountApiParams, } from "services/entities/team_policies"; -import globalPoliciesAPI from "services/entities/global_policies"; +import globalPoliciesAPI, { + IGlobalPoliciesApiQueryParams, +} from "services/entities/global_policies"; import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team"; import { QueryablePlatform, isQueryablePlatform } from "interfaces/platform"; @@ -164,10 +166,12 @@ function PoliciesPaginatedList( orderDirection: "asc" as const, orderKey: DEFAULT_SORT_COLUMN, teamId, + automationType: undefined, }; countQueryKey = { query: "", teamId, + automationType: undefined, }; } else { policiesQueryKey = { @@ -178,11 +182,13 @@ function PoliciesPaginatedList( orderKey: DEFAULT_SORT_COLUMN, teamId, mergeInherited: false, + automationType: undefined, }; countQueryKey = { query: "", teamId, mergeInherited: false, + automationType: undefined, }; } @@ -206,11 +212,18 @@ function PoliciesPaginatedList( ILoadAllPoliciesResponse, Error, IFormPolicy[] - >([policiesQueryKey], () => globalPoliciesAPI.loadAllNew(policiesQueryKey), { - enabled: teamId === APP_CONTEXT_ALL_TEAMS_ID, - keepPreviousData: true, - select: marshallApiResponse, - }); + >( + [policiesQueryKey], + () => + globalPoliciesAPI.loadAllNew( + policiesQueryKey as IGlobalPoliciesApiQueryParams + ), + { + enabled: teamId === APP_CONTEXT_ALL_TEAMS_ID, + keepPreviousData: true, + select: marshallApiResponse, + } + ); // Team policies query const { data: teamData, isFetching: teamIsLoading } = useQuery< @@ -232,10 +245,20 @@ function PoliciesPaginatedList( IPoliciesCountResponse, Error, number - >([countQueryKey], () => globalPoliciesAPI.getCount(countQueryKey), { - enabled: teamId === APP_CONTEXT_ALL_TEAMS_ID, - select: (countResponse: IPoliciesCountResponse) => countResponse.count, - }); + >( + [countQueryKey], + () => + globalPoliciesAPI.getCount( + countQueryKey as Pick< + IGlobalPoliciesApiQueryParams, + "query" | "automationType" + > + ), + { + enabled: teamId === APP_CONTEXT_ALL_TEAMS_ID, + select: (countResponse: IPoliciesCountResponse) => countResponse.count, + } + ); // Team count query const { data: teamCount, isFetching: teamIsFetchingCount } = useQuery< diff --git a/frontend/services/entities/global_policies.ts b/frontend/services/entities/global_policies.ts index 0cf44c0ca4b..25e414b436b 100644 --- a/frontend/services/entities/global_policies.ts +++ b/frontend/services/entities/global_policies.ts @@ -11,21 +11,28 @@ import { buildQueryStringFromParams, convertParamsToSnakeCase, } from "utilities/url"; +import { AutomationType } from "./team_policies"; -interface IPoliciesApiParams { +export type GlobalPoliciesAutomationType = Exclude< + AutomationType, + "software" | "scripts" | "conditional_access" | "calendar" +>; + +export interface IGlobalPoliciesApiQueryParams { page?: number; perPage?: number; orderKey?: string; orderDirection?: "asc" | "desc"; query?: string; + automationType?: GlobalPoliciesAutomationType; } -export interface IPoliciesQueryKey extends IPoliciesApiParams { +export interface IPoliciesQueryKey extends IGlobalPoliciesApiQueryParams { scope: "globalPolicies"; } export interface IPoliciesCountQueryKey - extends Pick { + extends Pick { scope: "policiesCount"; } @@ -68,7 +75,8 @@ export default { orderKey = ORDER_KEY, orderDirection: orderDir = ORDER_DIRECTION, query, - }: IPoliciesApiParams): Promise => { + automationType, + }: IGlobalPoliciesApiQueryParams): Promise => { const { GLOBAL_POLICIES } = endpoints; const queryParams = { @@ -77,6 +85,7 @@ export default { orderKey, orderDirection: orderDir, query, + automationType, }; const snakeCaseParams = convertParamsToSnakeCase(queryParams); @@ -87,11 +96,16 @@ export default { }, getCount: ({ query, - }: Pick): Promise => { + automationType, + }: Pick< + IGlobalPoliciesApiQueryParams, + "query" | "automationType" + >): Promise => { const { GLOBAL_POLICIES } = endpoints; const path = `${GLOBAL_POLICIES}/count`; const queryParams = { query, + automationType, }; const snakeCaseParams = convertParamsToSnakeCase(queryParams); const queryString = buildQueryStringFromParams(snakeCaseParams); diff --git a/frontend/services/entities/team_policies.ts b/frontend/services/entities/team_policies.ts index 6661cf2f238..39614a5ae71 100644 --- a/frontend/services/entities/team_policies.ts +++ b/frontend/services/entities/team_policies.ts @@ -11,6 +11,14 @@ import { } from "interfaces/policy"; import { API_NO_TEAM_ID } from "interfaces/team"; import { buildQueryStringFromParams, QueryParams } from "utilities/url"; +import { GlobalPoliciesAutomationType } from "./global_policies"; + +export type AutomationType = + | "software" + | "scripts" + | "calendar" + | "conditional_access" + | "other"; interface IPoliciesApiQueryParams { page?: number; @@ -18,7 +26,7 @@ interface IPoliciesApiQueryParams { orderKey?: string; orderDirection?: "asc" | "desc"; query?: string; - automationType?: string; + automationType?: AutomationType | GlobalPoliciesAutomationType; } export interface IPoliciesApiParams extends IPoliciesApiQueryParams { @@ -31,7 +39,10 @@ export interface ITeamPoliciesQueryKey extends IPoliciesApiParams { } export interface ITeamPoliciesCountQueryKey - extends Pick { + extends Pick< + IPoliciesApiParams, + "query" | "teamId" | "mergeInherited" | "automationType" + > { scope: "teamPoliciesCountMergeInherited" | "teamPoliciesCount"; } @@ -39,6 +50,7 @@ export interface IPoliciesCountApiParams { teamId: number; query?: string; mergeInherited?: boolean; + automationType?: AutomationType; } const ORDER_KEY = "name"; @@ -179,15 +191,17 @@ export default { query, teamId, mergeInherited = true, + automationType, }: Pick< IPoliciesCountApiParams, - "query" | "teamId" | "mergeInherited" + "query" | "teamId" | "mergeInherited" | "automationType" >): Promise => { const { TEAM_POLICIES } = endpoints; const path = `${TEAM_POLICIES(teamId)}/count`; const queryParams = { query, mergeInherited, + automationType, }; const snakeCaseParams = convertParamsToSnakeCase(queryParams); const queryString = buildQueryStringFromParams(snakeCaseParams); From fc99e01f668e8bfb9bf1487357fdf61eb2790d27 Mon Sep 17 00:00:00 2001 From: RachelElysia <71795832+RachelElysia@users.noreply.github.com> Date: Thu, 19 Mar 2026 06:44:34 -0400 Subject: [PATCH 024/141] Fleet UI: DEP issue dashboard and host filter (#41963) --- frontend/components/icons/ABMIssueHosts.tsx | 48 +++++++++++++++++ frontend/components/icons/index.ts | 2 + frontend/interfaces/host_summary.ts | 1 + .../pages/DashboardPage/DashboardPage.tsx | 3 ++ .../cards/ABMIssueHosts/ABMIssueHosts.tsx | 54 +++++++++++++++++++ .../cards/ABMIssueHosts/index.ts | 1 + .../cards/HostCountCard/HostCountCard.tsx | 2 +- .../MetricsHostCounts/MetricsHostCounts.tsx | 14 +++++ .../hosts/ManageHostsPage/ManageHostsPage.tsx | 14 ++++- .../FilterPill/FilterPill.tests.tsx | 23 +++++--- .../components/FilterPill/FilterPill.tsx | 51 +++++++++--------- .../components/FilterPill/_styles.scss | 1 + .../HostsFilterBlock/HostsFilterBlock.tsx | 19 ++++++- .../components/HostsFilterBlock/_styles.scss | 8 +++ frontend/services/entities/hosts.ts | 10 +++- frontend/utilities/url/index.ts | 4 ++ 16 files changed, 217 insertions(+), 38 deletions(-) create mode 100644 frontend/components/icons/ABMIssueHosts.tsx create mode 100644 frontend/pages/DashboardPage/cards/ABMIssueHosts/ABMIssueHosts.tsx create mode 100644 frontend/pages/DashboardPage/cards/ABMIssueHosts/index.ts diff --git a/frontend/components/icons/ABMIssueHosts.tsx b/frontend/components/icons/ABMIssueHosts.tsx new file mode 100644 index 00000000000..0f249053618 --- /dev/null +++ b/frontend/components/icons/ABMIssueHosts.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +const ABMIssueHosts = () => { + return ( + + + + + + + + + + + ); +}; + +export default ABMIssueHosts; diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts index 51d6a1c87c4..090bbd10a16 100644 --- a/frontend/components/icons/index.ts +++ b/frontend/components/icons/index.ts @@ -29,6 +29,7 @@ import Search from "./Search"; import LowDiskSpaceHosts from "./LowDiskSpaceHosts"; import MissingHosts from "./MissingHosts"; import TotalHosts from "./TotalHosts"; +import ABMIssueHosts from "./ABMIssueHosts"; import Lightbulb from "./Lightbulb"; import Apple from "./Apple"; @@ -97,6 +98,7 @@ export const ICON_MAP = { "low-disk-space-hosts": LowDiskSpaceHosts, "missing-hosts": MissingHosts, "total-hosts": TotalHosts, + "abm-issue-hosts": ABMIssueHosts, lightbulb: Lightbulb, info: Info, "info-outline": InfoOutline, diff --git a/frontend/interfaces/host_summary.ts b/frontend/interfaces/host_summary.ts index 3a16c7a3ada..957793712c8 100644 --- a/frontend/interfaces/host_summary.ts +++ b/frontend/interfaces/host_summary.ts @@ -15,5 +15,6 @@ export interface IHostSummary { new_count: number; missing_30_days_count?: number; // premium feature low_disk_space_count?: number; // premium feature + dep_assign_error_count?: number; // premium feature builtin_labels: ILabelSummary[]; } diff --git a/frontend/pages/DashboardPage/DashboardPage.tsx b/frontend/pages/DashboardPage/DashboardPage.tsx index 7d029fecfd3..6724e742b76 100644 --- a/frontend/pages/DashboardPage/DashboardPage.tsx +++ b/frontend/pages/DashboardPage/DashboardPage.tsx @@ -141,6 +141,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { const [androidCount, setAndroidCount] = useState(0); const [missingCount, setMissingCount] = useState(0); const [lowDiskSpaceCount, setLowDiskSpaceCount] = useState(0); + const [abmIssueCount, setAbmIssueCount] = useState(0); const [showActivityFeedTitle, setShowActivityFeedTitle] = useState(false); const [softwareTitleDetail, setSoftwareTitleDetail] = useState< JSX.Element | string | null @@ -223,6 +224,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { if (isPremiumTier) { setMissingCount(data.missing_30_days_count || 0); setLowDiskSpaceCount(data.low_disk_space_count || 0); + setAbmIssueCount(data.dep_assign_error_count || 0); } const macHosts = data.platforms?.find( (platform: IHostSummaryPlatforms) => platform.platform === "darwin" @@ -590,6 +592,7 @@ const DashboardPage = ({ router, location }: IDashboardProps): JSX.Element => { isPremiumTier={isPremiumTier} missingCount={missingCount} lowDiskSpaceCount={lowDiskSpaceCount} + abmIssueCount={abmIssueCount} selectedPlatformLabelId={selectedPlatformLabelId} /> diff --git a/frontend/pages/DashboardPage/cards/ABMIssueHosts/ABMIssueHosts.tsx b/frontend/pages/DashboardPage/cards/ABMIssueHosts/ABMIssueHosts.tsx new file mode 100644 index 00000000000..083f5a63ff8 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ABMIssueHosts/ABMIssueHosts.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import PATHS from "router/paths"; + +import { getPathWithQueryParams } from "utilities/url"; + +import HostCountCard from "../HostCountCard"; + +const baseClass = "hosts-abm-issue"; + +interface IABMIssueHostsProps { + abmIssueCount: number; + selectedPlatformLabelId?: number; + currentTeamId?: number; +} + +export const abmIssueTooltip = (): JSX.Element => { + return ( + + Hosts that have Apple Business Manager (ABM) profile assignment issue. + Migration or new Mac setup won't work. + + ); +}; + +const ABMIssueHosts = ({ + abmIssueCount, + selectedPlatformLabelId, + currentTeamId, +}: IABMIssueHostsProps): JSX.Element | null => { + // build the manage hosts URL filtered by missing and platform + const queryParams = { + dep_profile_error: true, + fleet_id: currentTeamId, + }; + + const endpoint = selectedPlatformLabelId + ? PATHS.MANAGE_HOSTS_LABEL(selectedPlatformLabelId) + : PATHS.MANAGE_HOSTS; + const path = getPathWithQueryParams(endpoint, queryParams); + + return ( + + ); +}; + +export default ABMIssueHosts; diff --git a/frontend/pages/DashboardPage/cards/ABMIssueHosts/index.ts b/frontend/pages/DashboardPage/cards/ABMIssueHosts/index.ts new file mode 100644 index 00000000000..2a7638ef088 --- /dev/null +++ b/frontend/pages/DashboardPage/cards/ABMIssueHosts/index.ts @@ -0,0 +1 @@ +export { default } from "./ABMIssueHosts"; diff --git a/frontend/pages/DashboardPage/cards/HostCountCard/HostCountCard.tsx b/frontend/pages/DashboardPage/cards/HostCountCard/HostCountCard.tsx index 3358b153fae..d08e3e538fd 100644 --- a/frontend/pages/DashboardPage/cards/HostCountCard/HostCountCard.tsx +++ b/frontend/pages/DashboardPage/cards/HostCountCard/HostCountCard.tsx @@ -13,7 +13,7 @@ interface IHostCountCard { title: string; iconName: IconNames; path: string; - tooltip?: string; + tooltip?: JSX.Element | string; notSupported?: boolean; className?: string; iconPosition?: "top" | "left"; diff --git a/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx b/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx index 3b3fd67acf3..c9080dfc8d0 100644 --- a/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx +++ b/frontend/pages/DashboardPage/sections/MetricsHostCounts/MetricsHostCounts.tsx @@ -6,6 +6,7 @@ import { PlatformValueOptions } from "utilities/constants"; import LowDiskSpaceHosts from "../../cards/LowDiskSpaceHosts"; import MissingHosts from "../../cards/MissingHosts"; import TotalHosts from "../../cards/TotalHosts"; +import ABMIssueHosts from "../../cards/ABMIssueHosts"; const baseClass = "metrics-host-counts"; @@ -16,6 +17,7 @@ interface IPlatformHostCountsProps { isPremiumTier?: boolean; missingCount: number; lowDiskSpaceCount: number; + abmIssueCount: number; selectedPlatformLabelId?: number; } @@ -26,6 +28,7 @@ const MetricsHostCounts = ({ isPremiumTier, missingCount, lowDiskSpaceCount, + abmIssueCount, selectedPlatformLabelId, }: IPlatformHostCountsProps): JSX.Element => { const TotalHostsCard = ( @@ -54,6 +57,16 @@ const MetricsHostCounts = ({ /> ); + // Does not render if abmIssueCount is 0 or undefined (e.g. on non-Apple platforms views) + // Currently all undefined is defaulted to 0 upstream + const ABMIssueHostsCard = abmIssueCount ? ( + + ) : null; + return (
{selectedPlatform === "all" && TotalHostsCard} @@ -66,6 +79,7 @@ const MetricsHostCounts = ({ {LowDiskSpaceHostsCard} )} + {ABMIssueHostsCard}
); }; diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index b1a66ad3ff1..317a7fcb4a4 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -81,6 +81,7 @@ import { PolicyResponse, } from "utilities/constants"; import { getNextLocationPath } from "utilities/helpers"; +import { strToBool } from "utilities/strings/stringUtils"; import Button from "components/buttons/Button"; import Icon from "components/Icon/Icon"; @@ -311,6 +312,7 @@ const ManageHostsPage = ({ const scriptBatchExecutionStatus: ScriptBatchHostCountV1 = queryParams?.[HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_STATUS] ?? (scriptBatchExecutionId ? "ran" : undefined); + const depProfileError = queryParams?.dep_profile_error; // ========= routeParams const { active_label: activeLabel, label_id: labelID } = routeParams; @@ -351,6 +353,7 @@ const ManageHostsPage = ({ // scriptBatchExecutionStatus // configProfileStatus || // configProfileUUID + // depProfileError const runScriptBatchFilterNotSupported = !!( // all above, except acceptable filters @@ -388,7 +391,8 @@ const ManageHostsPage = ({ scriptBatchExecutionId || scriptBatchExecutionStatus || configProfileStatus || - configProfileUUID + configProfileUUID || + depProfileError ) ); @@ -572,6 +576,7 @@ const ManageHostsPage = ({ configProfileUUID, scriptBatchExecutionStatus, scriptBatchExecutionId, + depProfileError: strToBool(depProfileError), }, ], ({ queryKey }) => hostsAPI.loadHosts(queryKey[0]), @@ -1098,6 +1103,8 @@ const ManageHostsPage = ({ newQueryParams[ HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_ID ] = scriptBatchExecutionId; + } else if (depProfileError) { + newQueryParams.dep_profile_error = depProfileError; } router.replace( @@ -1337,6 +1344,7 @@ const ManageHostsPage = ({ osSettings: osSettingsStatus, diskEncryptionStatus, vulnerability, + depProfileError, }) : hostsAPI.transferToTeam(teamId, selectedHostIds); @@ -1839,7 +1847,8 @@ const ManageHostsPage = ({ lowDiskSpaceHosts || osSettingsStatus || diskEncryptionStatus || - vulnerability + vulnerability || + depProfileError ); return ( @@ -1986,6 +1995,7 @@ const ManageHostsPage = ({ scriptBatchExecutionId, scriptBatchRanAt: scriptBatchSummary?.created_at || null, scriptBatchScriptName: scriptBatchSummary?.script_name || null, + depProfileError, }} selectedLabel={selectedLabel} isOnlyObserver={isOnlyObserver} diff --git a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tests.tsx b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tests.tsx index d13dd566fa5..73110cc1fdc 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tests.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tests.tsx @@ -1,7 +1,6 @@ import React from "react"; import { noop } from "lodash"; -import { render, screen, within } from "@testing-library/react"; - +import { render, screen, within, waitFor } from "@testing-library/react"; import { renderWithSetup } from "test/test-utils"; import FilterPill from "./FilterPill"; @@ -21,8 +20,8 @@ describe("Filter Pill Component", () => { ).toBeInTheDocument(); }); - it("renders a passed in string tooltip", () => { - render( + it("renders a passed in string tooltip", async () => { + const { user } = renderWithSetup( { /> ); - expect(screen.getByText("Test Tooltip")).toBeInTheDocument(); + await user.hover(screen.getByText("Test Pill")); + await waitFor(() => { + const tooltip = screen.getByText("Test Tooltip"); + expect(tooltip).toBeInTheDocument(); + }); }); - it("renders a passed in ReactNode tooltip", () => { - render( + it("renders a passed in ReactNode tooltip", async () => { + const { user } = renderWithSetup( This is a ReactNode

} @@ -42,7 +45,11 @@ describe("Filter Pill Component", () => { /> ); - expect(screen.getByText("This is a ReactNode")).toBeInTheDocument(); + await user.hover(screen.getByText("Test Pill")); + await waitFor(() => { + const tooltip = screen.getByText("This is a ReactNode"); + expect(tooltip).toBeInTheDocument(); + }); }); it("calls the onCancel callback when a user clicks on the remove button", async () => { diff --git a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tsx b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tsx index ed3ec314886..b3133497bcf 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/FilterPill.tsx @@ -1,13 +1,13 @@ -import React, { ReactNode, useRef } from "react"; -import ReactTooltip from "react-tooltip"; +import React, { ReactNode, useRef, useState } from "react"; import classnames from "classnames"; import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement"; -import { COLORS } from "styles/var/colors"; import Button from "components/buttons/Button"; import Icon from "components/Icon"; import { IconNames } from "components/icons"; +import TooltipWrapper from "components/TooltipWrapper"; +import { set } from "lodash"; interface IFilterPillProps { label: string; @@ -33,10 +33,30 @@ const FilterPill = ({ const pillText = useRef(null); const isTruncated = useCheckTruncatedElement(pillText); + const [tooltipContent, setTooltipContent] = useState(tooltipDescription); // if tooltipDescription not provided, behave like TooltipTruncatedText - const tooltipContent = - tooltipDescription ?? (isTruncated ? label : undefined); + if (isTruncated && !tooltipContent) { + setTooltipContent(label); + } + + const labelWithTooltip = tooltipContent ? ( + + + {label} + + + ) : ( + + {label} + + ); return (
{icon && } - - {label} - + {labelWithTooltip}
- {tooltipContent && ( - - {tooltipContent} - - )}
); diff --git a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss index 75a357af29a..3603e130d46 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/components/FilterPill/_styles.scss @@ -26,6 +26,7 @@ } &__tooltip-text { + display: block; @include ellipse-text(166px); } } diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx index f9db2711667..86af432f59a 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/HostsFilterBlock.tsx @@ -38,6 +38,7 @@ import { import Dropdown from "components/forms/fields/Dropdown"; import Button from "components/buttons/Button"; import Icon from "components/Icon/Icon"; +import { abmIssueTooltip } from "pages/DashboardPage/cards/ABMIssueHosts/ABMIssueHosts"; import FilterPill from "../FilterPill"; import PoliciesFilter from "../PoliciesFilter"; @@ -92,6 +93,7 @@ interface IHostsFilterBlockProps { scriptBatchExecutionId?: string; scriptBatchRanAt: string | null; scriptBatchScriptName: string | null; + depProfileError: string; // string "true" as we don't handle booleans }; selectedLabel?: ILabel; isOnlyObserver?: boolean; @@ -153,6 +155,7 @@ const HostsFilterBlock = ({ scriptBatchExecutionId, scriptBatchRanAt, scriptBatchScriptName, + depProfileError, }, selectedLabel, isOnlyObserver, @@ -604,6 +607,17 @@ const HostsFilterBlock = ({ ); }; + const renderDepProfileError = () => { + return ( + handleClearFilter(["dep_profile_error"])} + /> + ); + }; + const showSelectedLabel = selectedLabel && selectedLabel.type !== "all" && @@ -628,7 +642,8 @@ const HostsFilterBlock = ({ bootstrapPackageStatus || vulnerability || (configProfileStatus && configProfileUUID && configProfile) || - (scriptBatchExecutionStatus && scriptBatchExecutionId) + (scriptBatchExecutionStatus && scriptBatchExecutionId) || + depProfileError ) { const renderFilterPill = () => { switch (true) { @@ -702,6 +717,8 @@ const HostsFilterBlock = ({ return renderConfigProfileStatusBlock(); case !!scriptBatchExecutionStatus && !!scriptBatchExecutionId: return renderScriptBatchExecutionBlock(); + case !!depProfileError: + return renderDepProfileError(); default: return null; } diff --git a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/_styles.scss b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/_styles.scss index cd1dbdf8af5..fec6acaf86e 100644 --- a/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/components/HostsFilterBlock/_styles.scss @@ -41,3 +41,11 @@ gap: $pad-small; } } +.hosts-filter-block__abm-issue-filter-pill { + .filter-pill__tooltip-text { + max-width: min-content; // Fit pill text without truncation + } + .react-tooltip { + max-width: 250px; + } +} diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index b46175fbffe..0ad95f625e8 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -103,6 +103,7 @@ export interface ILoadHostsOptions { configProfileUUID?: string; scriptBatchExecutionStatus?: ScriptBatchHostCountV1; scriptBatchExecutionId?: string; + depProfileError?: boolean; } export interface IExportHostsOptions { @@ -139,6 +140,7 @@ export interface IExportHostsOptions { configProfileStatus?: string; scriptBatchExecutionStatus?: ScriptBatchHostCountV1; scriptBatchExecutionId?: string; + depProfileError?: boolean; } export interface IActionByFilter { @@ -167,6 +169,7 @@ export interface IActionByFilter { vulnerability?: string; scriptBatchExecutionStatus?: ScriptBatchHostCountV1; scriptBatchExecutionId?: string; + depProfileError?: boolean; } export interface IGetHostSoftwareResponse { @@ -360,11 +363,11 @@ export default { const configProfileStatus = options?.configProfileStatus; const scriptBatchExecutionStatus = options?.scriptBatchExecutionStatus; const scriptBatchExecutionId = options?.scriptBatchExecutionId; + const depProfileError = options?.depProfileError; if (!sortBy.length) { throw Error("sortBy is a required field."); } - const queryParams = { order_key: sortBy[0].key, order_direction: sortBy[0].direction, @@ -399,6 +402,7 @@ export default { configProfileStatus, scriptBatchExecutionStatus, scriptBatchExecutionId, + depProfileError, }), status, label_id: label, @@ -444,6 +448,7 @@ export default { configProfileUUID, scriptBatchExecutionStatus, scriptBatchExecutionId, + depProfileError, }: ILoadHostsOptions): Promise => { const label = getLabel(selectedLabels); const sortParams = getSortParams(sortBy); @@ -486,6 +491,7 @@ export default { configProfileUUID, scriptBatchExecutionStatus, scriptBatchExecutionId, + depProfileError, }), }; @@ -556,6 +562,7 @@ export default { osSettings, diskEncryptionStatus, vulnerability, + depProfileError, }: IActionByFilter) => { const { HOSTS_TRANSFER_BY_FILTER } = endpoints; return sendRequest("POST", HOSTS_TRANSFER_BY_FILTER, { @@ -583,6 +590,7 @@ export default { os_settings: osSettings, os_settings_disk_encryption: diskEncryptionStatus, vulnerability, + dep_profile_error: depProfileError, }, }); }, diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts index e033cca3090..2f1074fd0aa 100644 --- a/frontend/utilities/url/index.ts +++ b/frontend/utilities/url/index.ts @@ -50,6 +50,7 @@ interface IMutuallyExclusiveHostParams { configProfileUUID?: string; scriptBatchExecutionStatus?: string; scriptBatchExecutionId?: string; + depProfileError?: boolean; } export const parseQueryValueToNumberOrUndefined = ( @@ -224,6 +225,7 @@ export const reconcileMutuallyExclusiveHostParams = ({ configProfileUUID, scriptBatchExecutionStatus, scriptBatchExecutionId, + depProfileError, }: IMutuallyExclusiveHostParams): Record => { if (label) { // backend api now allows (label + low disk space) OR (label + mdm id) OR @@ -297,6 +299,8 @@ export const reconcileMutuallyExclusiveHostParams = ({ [HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_STATUS]: scriptBatchExecutionStatus, [HOSTS_QUERY_PARAMS.SCRIPT_BATCH_EXECUTION_ID]: scriptBatchExecutionId, }; + case !!depProfileError: + return { dep_profile_error: true }; default: return {}; } From fbc5b9d8b6efe021d1f14e5a495bcc7f1f2c17f3 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Thu, 19 Mar 2026 07:01:00 -0500 Subject: [PATCH 025/141] Updated go to 1.26.1 (#42027) **Related issue:** Resolves #41749 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. --- Dockerfile-desktop-linux | 2 +- changes/update-go-1.26.1 | 1 + go.mod | 2 +- infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile | 2 +- orbit/changes/update-go-1.26.1 | 1 + third_party/vuln-check/go.mod | 2 +- tools/github-manage/go.mod | 2 +- tools/mdm/migration/mdmproxy/Dockerfile | 2 +- tools/mdm/windows/bitlocker/go.mod | 2 +- tools/qacheck/go.mod | 2 +- tools/snapshot/go.mod | 2 +- tools/terraform/go.mod | 2 +- 12 files changed, 12 insertions(+), 10 deletions(-) create mode 100644 changes/update-go-1.26.1 create mode 100644 orbit/changes/update-go-1.26.1 diff --git a/Dockerfile-desktop-linux b/Dockerfile-desktop-linux index db24f9380a0..4c07f5b5da2 100644 --- a/Dockerfile-desktop-linux +++ b/Dockerfile-desktop-linux @@ -1,4 +1,4 @@ -FROM --platform=linux/amd64 golang:1.25.7-trixie@sha256:dfdd969010ba978942302cee078235da13aef030d22841e873545001d68a61a7 +FROM --platform=linux/amd64 golang:1.26.1-trixie@sha256:96b28783b99bcd265fbfe0b36a3ac6462416ce6bf1feac85d4c4ff533cbaa473 LABEL maintainer="Fleet Developers" RUN apt-get update && apt-get install -y musl-tools && rm -rf /var/lib/apt/lists/* diff --git a/changes/update-go-1.26.1 b/changes/update-go-1.26.1 new file mode 100644 index 00000000000..b87f97540c8 --- /dev/null +++ b/changes/update-go-1.26.1 @@ -0,0 +1 @@ +* Updated go to 1.26.1 diff --git a/go.mod b/go.mod index 96237377c40..58bde6fc7a1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/fleetdm/fleet/v4 -go 1.25.7 +go 1.26.1 require ( cloud.google.com/go/pubsub v1.49.0 diff --git a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile index dfb187afe27..18bd539020e 100644 --- a/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile +++ b/infrastructure/loadtesting/terraform/docker/loadtest.Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.7-alpine3.23@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced +FROM golang:1.26.1-alpine3.23@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 ARG TAG RUN apk add git sqlite gcc musl-dev sqlite-dev RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/cmd/osquery-perf/ && go build . diff --git a/orbit/changes/update-go-1.26.1 b/orbit/changes/update-go-1.26.1 new file mode 100644 index 00000000000..b87f97540c8 --- /dev/null +++ b/orbit/changes/update-go-1.26.1 @@ -0,0 +1 @@ +* Updated go to 1.26.1 diff --git a/third_party/vuln-check/go.mod b/third_party/vuln-check/go.mod index 052a73f5ad5..db24858e928 100644 --- a/third_party/vuln-check/go.mod +++ b/third_party/vuln-check/go.mod @@ -9,7 +9,7 @@ module github.com/fleetdm/fleet/v4/third_party/vuln-check -go 1.25.7 +go 1.26.1 require ( // NanoMDM - Apple MDM server (server/mdm/nanomdm/) diff --git a/tools/github-manage/go.mod b/tools/github-manage/go.mod index 135d5ca1694..2d07143580c 100644 --- a/tools/github-manage/go.mod +++ b/tools/github-manage/go.mod @@ -1,6 +1,6 @@ module fleetdm/gm -go 1.25.7 +go 1.26.1 require ( github.com/charmbracelet/bubbles v0.21.0 diff --git a/tools/mdm/migration/mdmproxy/Dockerfile b/tools/mdm/migration/mdmproxy/Dockerfile index ca3adc769eb..668f92c7508 100644 --- a/tools/mdm/migration/mdmproxy/Dockerfile +++ b/tools/mdm/migration/mdmproxy/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.7-alpine3.23@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced +FROM golang:1.26.1-alpine3.23@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 ARG TAG RUN apk update && apk add --no-cache git RUN git clone -b $TAG --depth=1 --no-tags --progress --no-recurse-submodules https://github.com/fleetdm/fleet.git && cd /go/fleet/tools/mdm/migration/mdmproxy && go build . diff --git a/tools/mdm/windows/bitlocker/go.mod b/tools/mdm/windows/bitlocker/go.mod index 68699c7aa15..d201dbcecbb 100755 --- a/tools/mdm/windows/bitlocker/go.mod +++ b/tools/mdm/windows/bitlocker/go.mod @@ -1,6 +1,6 @@ module bitlocker -go 1.25.7 +go 1.26.1 require github.com/go-ole/go-ole v1.3.0 diff --git a/tools/qacheck/go.mod b/tools/qacheck/go.mod index d175bd75b03..bc0b0772277 100644 --- a/tools/qacheck/go.mod +++ b/tools/qacheck/go.mod @@ -1,6 +1,6 @@ module qacheck -go 1.25.7 +go 1.26.1 require ( github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed diff --git a/tools/snapshot/go.mod b/tools/snapshot/go.mod index 81b8915ea74..823f46e13ce 100644 --- a/tools/snapshot/go.mod +++ b/tools/snapshot/go.mod @@ -1,6 +1,6 @@ module github.com/fleetdm/fleet/v4/tools/snapshot -go 1.25.7 +go 1.26.1 require ( github.com/manifoldco/promptui v0.9.0 diff --git a/tools/terraform/go.mod b/tools/terraform/go.mod index d46f0e61a8e..2eb3640f808 100644 --- a/tools/terraform/go.mod +++ b/tools/terraform/go.mod @@ -1,6 +1,6 @@ module terraform-provider-fleetdm -go 1.25.7 +go 1.26.1 require ( github.com/hashicorp/terraform-plugin-framework v1.7.0 From 73c386f207d3711bc59db9430284885d61e582d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:32:45 -0300 Subject: [PATCH 026/141] Bump google.golang.org/grpc from 1.78.0 to 1.79.3 (#42011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.78.0 to 1.79.3.
Release notes

Sourced from google.golang.org/grpc's releases.

Release 1.79.3

Security

  • server: fix an authorization bypass where malformed :path headers (missing the leading slash) could bypass path-based restricted "deny" rules in interceptors like grpc/authz. Any request with a non-canonical path is now immediately rejected with an Unimplemented error. (#8981)

Release 1.79.2

Bug Fixes

  • stats: Prevent redundant error logging in health/ORCA producers by skipping stats/tracing processing when no stats handler is configured. (grpc/grpc-go#8874)

Release 1.79.1

Bug Fixes

Release 1.79.0

API Changes

  • mem: Add experimental API SetDefaultBufferPool to change the default buffer pool. (#8806)
  • experimental/stats: Update MetricsRecorder to require embedding the new UnimplementedMetricsRecorder (a no-op struct) in all implementations for forward compatibility. (#8780)

Behavior Changes

  • balancer/weightedtarget: Remove handling of Addresses and only handle Endpoints in resolver updates. (#8841)

New Features

  • experimental/stats: Add support for asynchronous gauge metrics through the new AsyncMetricReporter and RegisterAsyncReporter APIs. (#8780)
  • pickfirst: Add support for weighted random shuffling of endpoints, as described in gRFC A113.
    • This is enabled by default, and can be turned off using the environment variable GRPC_EXPERIMENTAL_PF_WEIGHTED_SHUFFLING. (#8864)
  • xds: Implement :authority rewriting, as specified in gRFC A81. (#8779)
  • balancer/randomsubsetting: Implement the random_subsetting LB policy, as specified in gRFC A68. (#8650)

Bug Fixes

  • credentials/tls: Fix a bug where the port was not stripped from the authority override before validation. (#8726)
  • xds/priority: Fix a bug causing delayed failover to lower-priority clusters when a higher-priority cluster is stuck in CONNECTING state. (#8813)
  • health: Fix a bug where health checks failed for clients using legacy compression options (WithDecompressor or RPCDecompressor). (#8765)
  • transport: Fix an issue where the HTTP/2 server could skip header size checks when terminating a stream early. (#8769)
  • server: Propagate status detail headers, if available, when terminating a stream during request header processing. (#8754)

Performance Improvements

  • credentials/alts: Optimize read buffer alignment to reduce copies. (#8791)
  • mem: Optimize pooling and creation of buffer objects. (#8784)
  • transport: Reduce slice re-allocations by reserving slice capacity. (#8797)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=google.golang.org/grpc&package-manager=go_modules&previous-version=1.78.0&new-version=1.79.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/fleetdm/fleet/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 58bde6fc7a1..76b84e8d94d 100644 --- a/go.mod +++ b/go.mod @@ -174,7 +174,7 @@ require ( golang.org/x/text v0.33.0 golang.org/x/tools v0.40.0 google.golang.org/api v0.256.0 - google.golang.org/grpc v1.78.0 + google.golang.org/grpc v1.79.3 gopkg.in/guregu/null.v3 v3.5.0 gopkg.in/ini.v1 v1.67.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 diff --git a/go.sum b/go.sum index ee1a9eb99e3..862e0eaae63 100644 --- a/go.sum +++ b/go.sum @@ -207,8 +207,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= -github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= github.com/containerd/containerd v1.7.29 h1:90fWABQsaN9mJhGkoVnuzEY+o1XDPbg9BTC9QTAHnuE= @@ -308,12 +308,12 @@ github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FM github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= -github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= -github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= -github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8= github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c h1:KqlxcP2nuOcMjudCvK0qME2K/aFBDH+xcvYv7HYQaYc= @@ -1122,8 +1122,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= From 33fbf0b4861af73e75fd596e69491ce22843c62a Mon Sep 17 00:00:00 2001 From: Nine9one Date: Thu, 19 Mar 2026 13:43:18 +0100 Subject: [PATCH 027/141] Typo fix README.md (#41952) --- handbook/sales/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/sales/README.md b/handbook/sales/README.md index 8a44432e646..62a8706d587 100644 --- a/handbook/sales/README.md +++ b/handbook/sales/README.md @@ -115,7 +115,7 @@ In order to be transparent, Fleet sends order forms within 30 days of opportunit > Check out this example of an [order form review](https://fleetdm.zoom.us/clips/share/M79m0GZUS_GmF1R7go5T7A)! 1. Navigate to the Salesforce opportunity and click the "Create a subscription order form" link (top-right corner of the op page) to copy the "[TEMPLATE - Order form](https://docs.google.com/document/u/0/?tgif=d&ftv=1). -2. Move the order for to the "[Contract drafts](https://drive.google.com/drive/u/0/folders/1G1JTpFxhKZZzmn2L2RppohCX5Bv_CQ9c)" folder in Google Drive. +2. Move the order form to the "[Contract drafts](https://drive.google.com/drive/u/0/folders/1G1JTpFxhKZZzmn2L2RppohCX5Bv_CQ9c)" folder in Google Drive. 3. Edit the order form to be specific to the customer (e.g. add/remove table rows as needed for multi-year deals). Where possible, include a graphic of the customer's logo. Use good judgment and omit if a high-quality graphic is unavailable. If in doubt, ask IT & Enablement for help. > **IMPORTANT** To ensure product language is consistent, any changes to the standard order form template (including subscription appendix) must be submitted to ["πŸ¦’πŸ—£ Design review (:help-design)"](https://github.com/orgs/fleetdm/projects/93) for approval. Any "Free" or "Initial deployment period" greater than three (3) months requires CEO approval. From 72abb2397e545bce3812ee72345d37da4968306a Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:51:27 +0000 Subject: [PATCH 028/141] Add Head of Strategic Growth position (#42050) ## Summary - Adds a new "Head of Strategic Growth" position entry to `handbook/company/open-positions.yml`, fully commented out with YAML `#` comment syntax so it does **not** appear live on the website. - The position focuses on Fleet's largest and most complex revenue opportunities, including large enterprise organizations, strategic technology alliances, and OEM platform partnerships. - Follows the same structure/format as other commented-out positions in the file. - Updated hiring manager fields to Chaz MacLaughlin. --- Built for [Isabell Reedy](https://fleetdm.slack.com/archives/D0AEGJCGJR0/p1773924170012309?thread_ts=1773923576.362749&cid=D0AEGJCGJR0) by [Kilo for Slack](https://kilo.ai/features/slack-integration) --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> Co-authored-by: Isabell Reedy <113355639+ireedy@users.noreply.github.com> --- handbook/company/open-positions.yml | 83 ++++++++++++++++++----------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/handbook/company/open-positions.yml b/handbook/company/open-positions.yml index 2977d1710d5..5b70c3346b4 100644 --- a/handbook/company/open-positions.yml +++ b/handbook/company/open-positions.yml @@ -39,7 +39,7 @@ hiringManagerName: Chaz MacLaughlin hiringManagerLinkedInUrl: https://www.linkedin.com/in/chazmaclaughlin/ hiringManagerGithubUsername: chazmac6 - onTargetEarnings: '$200,000 - $280,000' + onTargetEarnings: "$200,000 - $280,000" responsibilities: | - 🀝 Develop and nurture strong, productive relationships with channel partners, executives, and sales teams, acting as the primary liaison for Fleet. - πŸ“ˆ Execute Fleet's channel strategy, supporting partner acquisition and achieving revenue targets through partner organizations. @@ -60,31 +60,31 @@ - βž• Bonus: Direct experience with Fleet, MDM, osquery or SQL query writing, and working with Client Platform Engineering, SRE, or Security Engineering teams. #- jobTitle: πŸ‹ Enterprise Customer Success Manager - # department: Customers - # hiringManagerName: Zay Hanlon - # hiringManagerGithubUsername: zayhanlon - # hiringManagerLinkedInUrl: https://www.linkedin.com/in/zayhanlon/ - # onTargetEarnings: "$130,000 - $160,000" - # responsibilities: | - # - ⏫ Own post-sales relationships with a portfolio of our customers and be responsible for advocating for their desired outcomes. - # - πŸ“£ Manage multiple Enterprise customer deployments and escalations simultaneously. - # - 🌑️ Work as a project manager alongside the customer and internal teams to establish an onboarding plan and define success criteria for new customers. - # - πŸͺ΄ Promote product adoption, referencability, and customer advocacy with key customer stakeholders. - # - πŸ•΄οΈ Utilize systems and tools to analyze pipeline and opportunity data for renewals and expansions, and keep all information up to date for leadership reporting. - # - πŸš€ Work collaboratively with product and engineering teams to facilitate feature development based on customer asks. - # - πŸ“ˆ Proactively engage with customers to identify potential health risk indicators and remove blockers to success and retention. - # - πŸ’‘ Excellent time management, communication and collaboration skills, with the ability to partner with various stakeholders to ensure customer key objectives for migration are top of mind. - # - πŸ§ͺ Extensive experience with Slack, Salesforce, Google Suite, and GitHub. - # experience: | - # - πŸ’­ Customer success management background supporting solutions IT/MDM solutions like Fleet, Intune, Workspace One, etc. - # - πŸ’– You know how to manage your time and priorities between customer escalations, customer renewals and expansions, and other day-to-day responsibilities. - # - ✍🏽 You have the ability to effectively influence key stakeholders, from senior executives to day-to-day engineering contacts, and drive Fleet's value with them. - # - 🧬 You care about delivering an outstanding customer experience and advocating for the customer's needs within Fleet. - # - πŸ§‘β€πŸ”¬ Experience working with Enterprise customers to coordinate resolution of complex technical issues and project manage intricate deployment initiatives. - # - 🀝 Collaboration: You work best in a team-based environment. You are decisive with the ability to shift gears between thinking and doing. - # - πŸ¦‰ 5+ years of work experience managing relationships with enterprise customers in the device management space. Experience with executing and tracking results tied to customer escalations, customer renewals, and customer expansion opportunities. - # - πŸ› οΈ You are outgoing, enjoy being customer facing, and have a passion for problem solving while assisting external stakeholders. - # - βž• Bonus: Familiarity with osquery, SQLite, GitOps workflows, Terraform, Tines and open source projects. Experience working with IT, SRE, CPE, or SecOps teams. +# department: Customers +# hiringManagerName: Zay Hanlon +# hiringManagerGithubUsername: zayhanlon +# hiringManagerLinkedInUrl: https://www.linkedin.com/in/zayhanlon/ +# onTargetEarnings: "$130,000 - $160,000" +# responsibilities: | +# - ⏫ Own post-sales relationships with a portfolio of our customers and be responsible for advocating for their desired outcomes. +# - πŸ“£ Manage multiple Enterprise customer deployments and escalations simultaneously. +# - 🌑️ Work as a project manager alongside the customer and internal teams to establish an onboarding plan and define success criteria for new customers. +# - πŸͺ΄ Promote product adoption, referencability, and customer advocacy with key customer stakeholders. +# - πŸ•΄οΈ Utilize systems and tools to analyze pipeline and opportunity data for renewals and expansions, and keep all information up to date for leadership reporting. +# - πŸš€ Work collaboratively with product and engineering teams to facilitate feature development based on customer asks. +# - πŸ“ˆ Proactively engage with customers to identify potential health risk indicators and remove blockers to success and retention. +# - πŸ’‘ Excellent time management, communication and collaboration skills, with the ability to partner with various stakeholders to ensure customer key objectives for migration are top of mind. +# - πŸ§ͺ Extensive experience with Slack, Salesforce, Google Suite, and GitHub. +# experience: | +# - πŸ’­ Customer success management background supporting solutions IT/MDM solutions like Fleet, Intune, Workspace One, etc. +# - πŸ’– You know how to manage your time and priorities between customer escalations, customer renewals and expansions, and other day-to-day responsibilities. +# - ✍🏽 You have the ability to effectively influence key stakeholders, from senior executives to day-to-day engineering contacts, and drive Fleet's value with them. +# - 🧬 You care about delivering an outstanding customer experience and advocating for the customer's needs within Fleet. +# - πŸ§‘β€πŸ”¬ Experience working with Enterprise customers to coordinate resolution of complex technical issues and project manage intricate deployment initiatives. +# - 🀝 Collaboration: You work best in a team-based environment. You are decisive with the ability to shift gears between thinking and doing. +# - πŸ¦‰ 5+ years of work experience managing relationships with enterprise customers in the device management space. Experience with executing and tracking results tied to customer escalations, customer renewals, and customer expansion opportunities. +# - πŸ› οΈ You are outgoing, enjoy being customer facing, and have a passion for problem solving while assisting external stakeholders. +# - βž• Bonus: Familiarity with osquery, SQLite, GitOps workflows, Terraform, Tines and open source projects. Experience working with IT, SRE, CPE, or SecOps teams. - jobTitle: πŸš€ Software Engineer department: Engineering @@ -114,13 +114,13 @@ - πŸ› οΈ Technical: You understand the software development processes. You understand that software quality matters. - 🟣 Openness: You are flexible and open to new ideas and ways of working. - βž• Bonus: Cybersecurity or IT background. - -- jobTitle: πŸ‹ Account Executive (EMEA) + +- jobTitle: πŸ‹ Account Executive (EMEA) department: Sales hiringManagerName: Chaz MacLaughlin hiringManagerLinkedInUrl: https://www.linkedin.com/in/chazmaclaughlin/ hiringManagerGithubUsername: chazmac6 - onTargetEarnings: '$200,000 - $350,000' + onTargetEarnings: "$200,000 - $350,000" responsibilities: | - πŸ–₯️ Present and demonstrate the value of Fleet's products, services, and upgrades to help current and future customers find win-win commercial agreements. - 🎯 Direct and participate in prospecting target companies, identifying key decision makers and influencers, leading when assigned/necessary/appropriate to help anticipate market trends and identify new opportunities for growth. @@ -152,7 +152,7 @@ hiringManagerName: Andrey Kizimenko hiringManagerGithubUsername: AndreyKizimenko hiringManagerLinkedInUrl: https://www.linkedin.com/in/andrey-kizimenko-988900214/ - onTargetEarnings: '$80,000 - $160,000' + onTargetEarnings: "$80,000 - $160,000" responsibilities: | - ⏫ Work closely with engineering to continually improve overall quality assurance efficiency and effectiveness throughout the product design and engineering process. - 🀝 Collaborate with the engineering managers and quality assurance engineers in the [product groups](https://fleetdm.com/handbook/company/product-groups#current-product-groups), actively participating in some engineering scrum meetings, sprint planning, daily standups, sprint demos, sprint retrospectives, and estimation sessions. @@ -175,7 +175,6 @@ - 🟣 Openness: You are flexible and open to new ideas and ways of working. - βž• Bonus: Cybersecurity or IT background. - #- jobTitle: 🌐 Solutions Consultant # department: IT & Enablement # hiringManagerName: Allen Houchins @@ -267,7 +266,6 @@ # - 🧬 You care about delivering an outstanding customer experience and advocating for the customer's needs within Fleet. # - βž• Bonus: Direct experience with Fleet, MDM, osquery or SQL query writing, and working with Client Platform Engineering, SRE, or Security Engineering teams. - # - jobTitle: πŸ”Ž Quality Analyst # department: Engineering # hiringManagerName: George Karr @@ -291,6 +289,27 @@ # - πŸ”„ Comfortable regularly repeating structured manual testing processes, regression testing, and feature validation. # - βž• Bonus: Hands-on experience testing across multiple operating systems and devices (macOS, Windows, Android, iOS, iPadOS). +# - jobTitle: πŸ‹ Head of Strategic Growth +# department: Sales +# hiringManagerName: Chaz MacLaughlin +# hiringManagerLinkedInUrl: https://www.linkedin.com/in/chazmaclaughlin/ +# hiringManagerGithubUsername: chazmac6 +# responsibilities: | +# - 🎯 Focus on Fleet's largest and most complex revenue opportunities, including large enterprise organizations, strategic technology alliances, and OEM platform partnerships. +# - 🌐 Operate without a geographic territory, collaborating with Fleet Account Executives, Customer Success Managers, Solution Consultants, and leadership to pursue high-impact opportunities that expand Fleet's platform across large environments. +# - 🏒 Target organizations with large endpoint environments, significant ARR potential, and strategic influence within the broader IT ecosystem. +# - πŸš€ Accelerate large strategic opportunities and platform expansion, complementing (not replacing or displacing) existing account ownership within the field sales organization. +# - πŸ“ˆ Drive strategic expansion within existing and new enterprise customers, including cross-sell of additional Fleet platform capabilities, enterprise-wide deployment initiatives, and expansion of managed endpoint coverage across business units, subsidiaries, or global environments. +# - ⏫ Identify and pursue upsell opportunities resulting from increased endpoint (EP) adoption. +# experience: | +# - πŸ¦‰ Proven experience in enterprise sales or strategic growth roles within B2B SaaS or enterprise technology, with a track record of closing large, complex deals. +# - 🀝 Demonstrated ability to collaborate cross-functionally with Account Executives, Customer Success, Solution Consultants, and executive leadership. +# - 🧠 Deep understanding of large enterprise IT environments, endpoint management, and platform expansion strategies. +# - πŸ“£ Exceptional communication, negotiation, and relationship-building skills with the ability to influence C-level and senior decision-makers. +# - ⏩ Thrive in a complex, fast-paced, results-driven environment with the ability to manage multiple high-stakes opportunities simultaneously. +# - πŸ§‘β€πŸ’» Love technology and can explain how things work in detail, with experience marketing to or working with IT engineers, security engineers, or DevOps teams. +# - βž• Bonus: Direct experience with Fleet, MDM, osquery or SQL query writing, and working with Client Platform Engineering, SRE, or Security Engineering teams. + #- jobTitle: πŸ’» IT Support Administrator # department: IT & Enablement # hiringManagerName: Allen Houchins From 675c89ccc91c5dfaa3d581249adcb1341bd9107c Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Thu, 19 Mar 2026 10:32:08 -0300 Subject: [PATCH 029/141] Add statistics for Entra conditional access (#41998) Resolves #41479 - Generic changes for the whole feature file included in the first commit. - Docs: https://github.com/fleetdm/fleet/pull/40861/changes ## Testing - [X] Added/updated automated tests - [X] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [X] QA'd all new/changed functionality manually --- cmd/fleet/serve_test.go | 2 +- server/datastore/mysql/statistics.go | 48 +++++++- server/datastore/mysql/statistics_test.go | 132 ++++++++++++++++++++++ server/fleet/statistics.go | 8 +- 4 files changed, 187 insertions(+), 3 deletions(-) diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index 179b20737cb..18865a4da3f 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -141,7 +141,7 @@ func TestMaybeSendStatistics(t *testing.T) { require.NoError(t, err) assert.True(t, recorded) require.True(t, cleanedup) - assert.Equal(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numHostsABMPending":888,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numQueries":200,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"mdmRecoveryLockPasswordEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"numHostsFleetDesktopEnabled":1984,"fleetMaintainedAppsMacOS":["1password/darwin"],"fleetMaintainedAppsWindows":["google-chrome/windows"],"oktaConditionalAccessConfigured":false,"conditionalAccessBypassDisabled":false}`, requestBody) + assert.Equal(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numHostsABMPending":888,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numQueries":200,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"mdmRecoveryLockPasswordEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"numHostsFleetDesktopEnabled":1984,"fleetMaintainedAppsMacOS":["1password/darwin"],"fleetMaintainedAppsWindows":["google-chrome/windows"],"conditionalAccessEnabled":false,"oktaConditionalAccessConfigured":false,"conditionalAccessBypassDisabled":false,"entraConditionalAccessConfigured":false}`, requestBody) } func TestMaybeSendStatisticsSkipsSendingIfNotNeeded(t *testing.T) { diff --git a/server/datastore/mysql/statistics.go b/server/datastore/mysql/statistics.go index 6a787d219c9..45629931751 100644 --- a/server/datastore/mysql/statistics.go +++ b/server/datastore/mysql/statistics.go @@ -171,11 +171,21 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du stats.FleetMaintainedAppsMacOS = fleetMaintainedAppsMacOS stats.FleetMaintainedAppsWindows = fleetMaintainedAppsWindows + stats.ConditionalAccessEnabled, err = ds.conditionalAccessEnabledOnATeam(ctx, teams) + if err != nil { + return ctxerr.Wrap(ctx, err, "conditional access enabled on a team") + } + if appConfig.ConditionalAccess != nil { stats.OktaConditionalAccessConfigured = appConfig.ConditionalAccess.OktaConfigured() stats.ConditionalAccessBypassDisabled = !appConfig.ConditionalAccess.BypassEnabled() } + stats.EntraConditionalAccessConfigured, err = ds.entraConditionalAccessConfigured(ctx, config) + if err != nil { + return ctxerr.Wrap(ctx, err, "entra conditional access configured") + } + return nil } @@ -265,7 +275,7 @@ func (ds *Datastore) getTableRowCountsViaInformationSchema(ctx context.Context) return nil, err } - var byName = make(map[string]uint) + byName := make(map[string]uint) for _, row := range results { byName[row.Table] = row.Rows } @@ -306,3 +316,39 @@ func fleetMaintainedAppsInUseDB(ctx context.Context, db sqlx.QueryerContext) (ma return macOSApps, windowsApps, nil } + +func (ds *Datastore) entraConditionalAccessConfigured(ctx context.Context, fleetConfig config.FleetConfig) (bool, error) { + // Check if the needed server configuration for Conditional Access is set. + if !fleetConfig.MicrosoftCompliancePartner.IsSet() { + return false, nil + } + + // Check if the integration is fully configured. + integration, err := ds.ConditionalAccessMicrosoftGet(ctx) + if err != nil { + if fleet.IsNotFound(err) { + return false, nil + } + return false, ctxerr.Wrap(ctx, err, "failed to load the integration") + } + return integration.SetupDone, nil +} + +func (ds *Datastore) conditionalAccessEnabledOnATeam(ctx context.Context, teams []*fleet.Team) (bool, error) { + // Check configuration for "Unassigned" is stored in the main appconfig. + cfg, err := ds.AppConfig(ctx) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "failed to load appconfig") + } + if cfg.Integrations.ConditionalAccessEnabled.Set && cfg.Integrations.ConditionalAccessEnabled.Value { + return true, nil + } + + // Check for the setting in teams. + for _, team := range teams { + if team.Config.Integrations.ConditionalAccessEnabled.Set && team.Config.Integrations.ConditionalAccessEnabled.Value { + return true, nil + } + } + return false, nil +} diff --git a/server/datastore/mysql/statistics_test.go b/server/datastore/mysql/statistics_test.go index 8a58d1aad80..6c831def968 100644 --- a/server/datastore/mysql/statistics_test.go +++ b/server/datastore/mysql/statistics_test.go @@ -26,6 +26,7 @@ func TestStatistics(t *testing.T) { fn func(t *testing.T, ds *Datastore) }{ {"ShouldSend", testStatisticsShouldSend}, + {"ConditionalAccessStatistics", testConditionalAccessStatistics}, {"FleetMaintainedAppsInUse", testFleetMaintainedAppsInUse}, } for _, c := range cases { @@ -98,6 +99,8 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) { assert.Equal(t, 0, stats.NumHostsFleetDesktopEnabled) assert.False(t, stats.OktaConditionalAccessConfigured) assert.False(t, stats.ConditionalAccessBypassDisabled) + assert.False(t, stats.ConditionalAccessEnabled) + assert.False(t, stats.EntraConditionalAccessConfigured) firstIdentifier := stats.AnonymousIdentifier @@ -246,6 +249,8 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) { assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled) assert.False(t, stats.OktaConditionalAccessConfigured) assert.False(t, stats.ConditionalAccessBypassDisabled) + assert.False(t, stats.ConditionalAccessEnabled) + assert.False(t, stats.EntraConditionalAccessConfigured) err = ds.RecordStatisticsSent(ctx) require.NoError(t, err) @@ -359,6 +364,8 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) { assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled) assert.False(t, stats.OktaConditionalAccessConfigured) assert.False(t, stats.ConditionalAccessBypassDisabled) + assert.False(t, stats.ConditionalAccessEnabled) + assert.False(t, stats.EntraConditionalAccessConfigured) // Create multiple new sessions for a single user _, err = ds.NewSession(ctx, u1.ID, 8) @@ -401,6 +408,8 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) { assert.Equal(t, 1, stats.NumHostsFleetDesktopEnabled) assert.False(t, stats.OktaConditionalAccessConfigured) assert.False(t, stats.ConditionalAccessBypassDisabled) + assert.False(t, stats.ConditionalAccessEnabled) + assert.False(t, stats.EntraConditionalAccessConfigured) // Add host to test hosts not responding stats _, err = ds.NewHost(ctx, &fleet.Host{ @@ -474,6 +483,129 @@ func testStatisticsShouldSend(t *testing.T, ds *Datastore) { assert.False(t, stats.ConditionalAccessBypassDisabled) } +func testConditionalAccessStatistics(t *testing.T, ds *Datastore) { + eh := ctxerr.MockHandler{} + eh.RetrieveImpl = func(flush bool) ([]*ctxerr.StoredError, error) { + return nil, nil + } + ctxb := context.Background() + ctx := ctxerr.NewContext(ctxb, eh) + + premiumLicense := &fleet.LicenseInfo{Tier: fleet.TierPremium, Organization: "Fleet"} + fleetConfig := config.FleetConfig{Osquery: config.OsqueryConfig{DetailUpdateInterval: 1 * time.Hour}} + + // Initial state: nothing configured + stats, shouldSend, err := ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig) + require.NoError(t, err) + assert.True(t, shouldSend) + assert.False(t, stats.ConditionalAccessEnabled) + assert.False(t, stats.EntraConditionalAccessConfigured) + + err = ds.RecordStatisticsSent(ctx) + require.NoError(t, err) + time.Sleep(1100 * time.Millisecond) + + // Enable conditional access on appconfig (for "No team") + cfg, err := ds.AppConfig(ctx) + require.NoError(t, err) + cfg.Integrations.ConditionalAccessEnabled = optjson.SetBool(true) + err = ds.SaveAppConfig(ctx, cfg) + require.NoError(t, err) + + stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig) + require.NoError(t, err) + assert.True(t, shouldSend) + assert.True(t, stats.ConditionalAccessEnabled) + assert.False(t, stats.EntraConditionalAccessConfigured) + + // Disable on appconfig + err = ds.RecordStatisticsSent(ctx) + require.NoError(t, err) + time.Sleep(1100 * time.Millisecond) + + cfg.Integrations.ConditionalAccessEnabled = optjson.SetBool(false) + err = ds.SaveAppConfig(ctx, cfg) + require.NoError(t, err) + + stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig) + require.NoError(t, err) + assert.True(t, shouldSend) + assert.False(t, stats.ConditionalAccessEnabled) + + // Enable conditional access on a team + err = ds.RecordStatisticsSent(ctx) + require.NoError(t, err) + time.Sleep(1100 * time.Millisecond) + + team, err := ds.NewTeam(ctx, &fleet.Team{ + Name: "ca-team", + Description: "team with conditional access", + }) + require.NoError(t, err) + team.Config.Integrations.ConditionalAccessEnabled = optjson.SetBool(true) + _, err = ds.SaveTeam(ctx, team) + require.NoError(t, err) + + stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig) + require.NoError(t, err) + assert.True(t, shouldSend) + assert.True(t, stats.ConditionalAccessEnabled) + + // Disable on team + err = ds.RecordStatisticsSent(ctx) + require.NoError(t, err) + time.Sleep(1100 * time.Millisecond) + + team.Config.Integrations.ConditionalAccessEnabled = optjson.SetBool(false) + _, err = ds.SaveTeam(ctx, team) + require.NoError(t, err) + + stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig) + require.NoError(t, err) + assert.True(t, shouldSend) + assert.False(t, stats.ConditionalAccessEnabled) + + // Test Entra conditional access: create the integration but without setup done + err = ds.RecordStatisticsSent(ctx) + require.NoError(t, err) + time.Sleep(1100 * time.Millisecond) + + fleetConfig.MicrosoftCompliancePartner = config.MicrosoftCompliancePartnerConfig{ + ProxyAPIKey: "test-key", + } + err = ds.ConditionalAccessMicrosoftCreateIntegration(ctx, "test-tenant", "test-secret") + require.NoError(t, err) + + stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig) + require.NoError(t, err) + assert.True(t, shouldSend) + assert.False(t, stats.EntraConditionalAccessConfigured) // setup not done yet + + // Mark setup done + err = ds.RecordStatisticsSent(ctx) + require.NoError(t, err) + time.Sleep(1100 * time.Millisecond) + + err = ds.ConditionalAccessMicrosoftMarkSetupDone(ctx) + require.NoError(t, err) + + stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig) + require.NoError(t, err) + assert.True(t, shouldSend) + assert.True(t, stats.EntraConditionalAccessConfigured) + + // Without the fleet config proxy key, should be false even with setup done + err = ds.RecordStatisticsSent(ctx) + require.NoError(t, err) + time.Sleep(1100 * time.Millisecond) + + fleetConfig.MicrosoftCompliancePartner = config.MicrosoftCompliancePartnerConfig{} + stats, shouldSend, err = ds.ShouldSendStatistics(license.NewContext(ctx, premiumLicense), time.Millisecond, fleetConfig) + require.NoError(t, err) + assert.True(t, shouldSend) + assert.False(t, stats.EntraConditionalAccessConfigured) +} + func testFleetMaintainedAppsInUse(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/fleet/statistics.go b/server/fleet/statistics.go index 1549d9bff39..6289e4e91e8 100644 --- a/server/fleet/statistics.go +++ b/server/fleet/statistics.go @@ -39,7 +39,7 @@ type StatisticsPayload struct { NumWeeklyPolicyViolationDaysActual int `json:"numWeeklyPolicyViolationDaysActual"` // NumWeeklyPolicyViolationDaysActual is an aggregate count of possible policy violation // days. The count is incremented by the organization's total number of policies - // mulitplied by the total number of hosts as of the time the count is incremented. The count + // multiplied by the total number of hosts as of the time the count is incremented. The count // increments once per 24-hour interval and resets each week. NumWeeklyPolicyViolationDaysPossible int `json:"numWeeklyPolicyViolationDaysPossible"` HostsEnrolledByOperatingSystem map[string][]HostsCountByOSVersion `json:"hostsEnrolledByOperatingSystem"` @@ -65,8 +65,14 @@ type StatisticsPayload struct { // FleetMaintainedAppsWindows is an array of Fleet-maintained app slugs being used on Windows FleetMaintainedAppsWindows []string `json:"fleetMaintainedAppsWindows,omitempty"` + // ConditionalAccessEnabled indicates whether any team has conditional access enabled. + ConditionalAccessEnabled bool `json:"conditionalAccessEnabled"` + // OktaConditionalAccessConfigured indicates if the Okta conditional access integration is configured. OktaConditionalAccessConfigured bool `json:"oktaConditionalAccessConfigured"` + // ConditionalAccessBypassDisabled indicates if bypass is disabled for Okta. ConditionalAccessBypassDisabled bool `json:"conditionalAccessBypassDisabled"` + // EntraConditionalAccessConfigured indicates if the Entra conditional access integration is configured. + EntraConditionalAccessConfigured bool `json:"entraConditionalAccessConfigured"` } type HostsCountByOrbitVersion struct { From be8e2d621348bb8383005495bda25f9cd75fedea Mon Sep 17 00:00:00 2001 From: Marko Lisica <83164494+marko-lisica@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:34:47 +0100 Subject: [PATCH 030/141] =?UTF-8?q?Setup=20experience=20guide:=20how=20adm?= =?UTF-8?q?in=20can=20let=20user=20through=20setup=20experien=E2=80=A6=20(?= =?UTF-8?q?#42045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added information about sending `DeviceConfigured` command to assist users stuck in setup experience. --- articles/setup-experience.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/articles/setup-experience.md b/articles/setup-experience.md index 5de2b3a2081..9447fac83ae 100644 --- a/articles/setup-experience.md +++ b/articles/setup-experience.md @@ -197,6 +197,8 @@ The Fleet setup experience for macOS will exit if any of the following occurs: * All setup steps complete, including failed installs or script runs, with the "Cancel setup if software install fails" option _not_ enabled (see ["Blocking setup on failed software installs"](https://fleetdm.com/guides/macos-setup-experience#install-software)). * The user presses Command (⌘) + Shift + X at any time during the setup process. +> If the end user is stuck, you can send the [DeviceConfigured](https://developer.apple.com/documentation/devicemanagement/device-configured-command) using Fleet's [Run MDM command](https://fleetdm.com/docs/rest-api/rest-api#run-mdm-command) API to let the user through. + ## Setup Assistant When an end user unboxes their new Apple device, or starts up a freshly wiped device, they're presented with the Setup Assistant. Here they see panes that allow them to configure accessibility, appearance, and more. From 50bc31caf78c7280e36b6d0f60883cbc80e1bf54 Mon Sep 17 00:00:00 2001 From: Jonathan Katz <44128041+jkatz01@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:50:18 -0400 Subject: [PATCH 031/141] Add automation_type filter to count policies endpoint (#42007) **Related issue:** Resolves #41987 # Checklist for submitter ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually - Tested with the "scripts" filter and >20 policies with that automation, and together with #41991 the policy count and pagination is correct --- server/datastore/mysql/policies.go | 46 ++++++++++---- server/datastore/mysql/policies_test.go | 81 ++++++++++++++++++------- server/fleet/datastore.go | 8 +-- server/fleet/service.go | 4 +- server/mock/datastore_mock.go | 24 ++++---- server/mock/service/service_mock.go | 12 ++-- server/service/global_policies.go | 2 +- server/service/team_policies.go | 11 ++-- 8 files changed, 124 insertions(+), 64 deletions(-) diff --git a/server/datastore/mysql/policies.go b/server/datastore/mysql/policies.go index 9f8414c5d6d..af097218f12 100644 --- a/server/datastore/mysql/policies.go +++ b/server/datastore/mysql/policies.go @@ -37,7 +37,7 @@ const policyCols = ` p.id, p.team_id, p.resolution, p.name, p.query, p.description, p.author_id, p.platforms, p.created_at, p.updated_at, p.critical, p.calendar_events_enabled, p.software_installer_id, p.script_id, - p.vpp_apps_teams_id, p.conditional_access_enabled, p.type, + p.vpp_apps_teams_id, p.conditional_access_enabled, p.type, p.patch_software_title_id ` @@ -860,7 +860,7 @@ func getInheritedPoliciesForTeam(ctx context.Context, q sqlx.QueryerContext, tea // CountPolicies returns the total number of team policies. // If teamID is nil, it returns the total number of global policies. -func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery string) (int, error) { +func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery string, automationType string) (int, error) { var ( query string args []interface{} @@ -874,6 +874,18 @@ func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery args = append(args, *teamID) } + if teamID != nil { + automationFilter, filterArgs, err := ds.createAutomationClause(ctx, automationType, *teamID) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "build automation filter clause") + } + + query += automationFilter + if len(filterArgs) > 0 { + args = append(args, filterArgs...) + } + } + // We must normalize the name for full Unicode support (Unicode equivalence). match := norm.NFC.String(matchQuery) query, args = searchLike(query, args, match, policySearchColumns...) @@ -886,18 +898,28 @@ func (ds *Datastore) CountPolicies(ctx context.Context, teamID *uint, matchQuery return count, nil } -func (ds *Datastore) CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string) (int, error) { +func (ds *Datastore) CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string, automationType string) (int, error) { var args []interface{} query := `SELECT count(*) FROM policies p WHERE (p.team_id = ? OR p.team_id IS NULL)` args = append(args, teamID) + automationFilter, filterArgs, err := ds.createAutomationClause(ctx, automationType, teamID) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "build automation filter clause") + } + + query += automationFilter + if len(filterArgs) > 0 { + args = append(args, filterArgs...) + } + // We must normalize the name for full Unicode support (Unicode equivalence). match := norm.NFC.String(matchQuery) query, args = searchLike(query, args, match, policySearchColumns...) var count int - err := sqlx.GetContext(ctx, ds.reader(ctx), &count, query, args...) + err = sqlx.GetContext(ctx, ds.reader(ctx), &count, query, args...) if err != nil { return 0, ctxerr.Wrap(ctx, err, "counting merged team policies") } @@ -1157,8 +1179,8 @@ func newTeamPolicy(ctx context.Context, db sqlx.ExtContext, teamID uint, authorI return policyDB(ctx, db, policyID, &teamID) } -func (ds *Datastore) ListTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) (teamPolicies, inheritedPolicies []*fleet.Policy, err error) { - filterClause, filterArgs, err := ds.createAutomationClause(ctx, automationFilter, teamID) +func (ds *Datastore) ListTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationType string) (teamPolicies, inheritedPolicies []*fleet.Policy, err error) { + filterClause, filterArgs, err := ds.createAutomationClause(ctx, automationType, teamID) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "build automation filter clause") } @@ -1175,10 +1197,10 @@ func (ds *Datastore) ListTeamPolicies(ctx context.Context, teamID uint, opts fle return teamPolicies, inheritedPolicies, err } -func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, error) { +func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, automationType string) ([]*fleet.Policy, error) { var args []interface{} - automationClause, filterArgs, err := ds.createAutomationClause(ctx, automationFilter, teamID) + automationFilter, filterArgs, err := ds.createAutomationClause(ctx, automationType, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "build automation filter clause") } @@ -1197,7 +1219,7 @@ func (ds *Datastore) ListMergedTeamPolicies(ctx context.Context, teamID uint, op AND (p.team_id IS NOT NULL OR ps.inherited_team_id = ?) WHERE (p.team_id = ? OR p.team_id IS NULL) %s - `, automationClause) + `, automationFilter) args = append(args, teamID, teamID) if len(filterArgs) > 0 { @@ -2663,9 +2685,9 @@ func (ds *Datastore) GetPatchPolicy(ctx context.Context, teamID *uint, titleID u return &policy, nil } -func (ds *Datastore) createAutomationClause(ctx context.Context, automationFilter string, teamID uint) (string, []any, error) { +func (ds *Datastore) createAutomationClause(ctx context.Context, automationType string, teamID uint) (string, []any, error) { // TODO: improve filtering by "other" - if automationFilter == "other" { + if automationType == "other" { team, err := ds.TeamLite(ctx, teamID) if err != nil { return "", nil, ctxerr.Wrap(ctx, err, "getting team config") @@ -2683,7 +2705,7 @@ func (ds *Datastore) createAutomationClause(ctx context.Context, automationFilte return clause, args, nil } - switch automationFilter { + switch automationType { case "software": return " AND (p.software_installer_id IS NOT NULL OR p.vpp_apps_teams_id IS NOT NULL OR p.type = 'patch')", nil, nil case "scripts": diff --git a/server/datastore/mysql/policies_test.go b/server/datastore/mysql/policies_test.go index fe60a7d9834..7bd17e11d85 100644 --- a/server/datastore/mysql/policies_test.go +++ b/server/datastore/mysql/policies_test.go @@ -3527,15 +3527,15 @@ func testCountPolicies(t *testing.T, ds *Datastore) { require.NoError(t, err) // no policies - globalCount, err := ds.CountPolicies(ctx, nil, "") + globalCount, err := ds.CountPolicies(ctx, nil, "", "") require.NoError(t, err) assert.Equal(t, 0, globalCount) - teamCount, err := ds.CountPolicies(ctx, &tm.ID, "") + teamCount, err := ds.CountPolicies(ctx, &tm.ID, "", "") require.NoError(t, err) assert.Equal(t, 0, teamCount) - mergedCount, err := ds.CountMergedTeamPolicies(ctx, tm.ID, "") + mergedCount, err := ds.CountMergedTeamPolicies(ctx, tm.ID, "", "") require.NoError(t, err) assert.Equal(t, 0, mergedCount) @@ -3545,15 +3545,15 @@ func testCountPolicies(t *testing.T, ds *Datastore) { require.NoError(t, err) } - globalCount, err = ds.CountPolicies(ctx, nil, "") + globalCount, err = ds.CountPolicies(ctx, nil, "", "") require.NoError(t, err) assert.Equal(t, 10, globalCount) - teamCount, err = ds.CountPolicies(ctx, &tm.ID, "") + teamCount, err = ds.CountPolicies(ctx, &tm.ID, "", "") require.NoError(t, err) assert.Equal(t, 0, teamCount) - mergedCount, err = ds.CountMergedTeamPolicies(ctx, tm.ID, "") + mergedCount, err = ds.CountMergedTeamPolicies(ctx, tm.ID, "", "") require.NoError(t, err) assert.Equal(t, 10, mergedCount) @@ -3563,30 +3563,35 @@ func testCountPolicies(t *testing.T, ds *Datastore) { require.NoError(t, err) } - teamCount, err = ds.CountPolicies(ctx, &tm.ID, "") + teamCount, err = ds.CountPolicies(ctx, &tm.ID, "", "") require.NoError(t, err) assert.Equal(t, 5, teamCount) - globalCount, err = ds.CountPolicies(ctx, nil, "") + globalCount, err = ds.CountPolicies(ctx, nil, "", "") require.NoError(t, err) assert.Equal(t, 10, globalCount) - mergedCount, err = ds.CountMergedTeamPolicies(ctx, tm.ID, "") + mergedCount, err = ds.CountMergedTeamPolicies(ctx, tm.ID, "", "") require.NoError(t, err) assert.Equal(t, 15, mergedCount) // test filter - globalCount, err = ds.CountPolicies(ctx, nil, "global policy 1") + globalCount, err = ds.CountPolicies(ctx, nil, "global policy 1", "") require.NoError(t, err) assert.Equal(t, 1, globalCount) - teamCount, err = ds.CountPolicies(ctx, &tm.ID, "team policy 1") + teamCount, err = ds.CountPolicies(ctx, &tm.ID, "team policy 1", "") require.NoError(t, err) assert.Equal(t, 1, teamCount) - mergedCount, err = ds.CountMergedTeamPolicies(ctx, tm.ID, "policy 1") + mergedCount, err = ds.CountMergedTeamPolicies(ctx, tm.ID, "policy 1", "") require.NoError(t, err) assert.Equal(t, 2, mergedCount) + + // test automation filter doesn't affect global policy count + globalCount, err = ds.CountPolicies(ctx, nil, "", "scripts") + require.NoError(t, err) + assert.Equal(t, 10, globalCount) } func testUpdatePolicyHostCounts(t *testing.T, ds *Datastore) { @@ -3816,10 +3821,10 @@ func testPoliciesNameUnicode(t *testing.T, ds *Datastore) { assert.Equal(t, equivalentNames[0], inheritedPolicies[0].Name) // CountPolicies - count, err := ds.CountPolicies(context.Background(), &team.ID, equivalentNames[1]) + count, err := ds.CountPolicies(context.Background(), &team.ID, equivalentNames[1], "") assert.NoError(t, err) assert.Equal(t, 1, count) - count, err = ds.CountPolicies(context.Background(), nil, equivalentNames[1]) + count, err = ds.CountPolicies(context.Background(), nil, equivalentNames[1], "") assert.NoError(t, err) assert.Equal(t, 1, count) } @@ -5655,7 +5660,7 @@ func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) { require.Equal(t, p, globalPolicy) ids = append(ids, globalPolicy.ID) } - c, err := ds.CountPolicies(ctx, nil, "") + c, err := ds.CountPolicies(ctx, nil, "", "") require.NoError(t, err) require.Equal(t, 2, c) globalPoliciesByID, err := ds.PoliciesByID(ctx, ids) @@ -5689,10 +5694,10 @@ func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, teamPoliciesByID, 1) require.Equal(t, teamPoliciesByID[teamPolicies[0].ID], teamPolicies[0]) - c, err = ds.CountMergedTeamPolicies(ctx, team1.ID, "") + c, err = ds.CountMergedTeamPolicies(ctx, team1.ID, "", "") require.NoError(t, err) require.Equal(t, 3, c) - c, err = ds.CountPolicies(ctx, &team1.ID, "") + c, err = ds.CountPolicies(ctx, &team1.ID, "", "") require.NoError(t, err) require.Equal(t, 1, c) mergedTeamPolicies, err := ds.ListMergedTeamPolicies(ctx, team1.ID, fleet.ListOptions{}, "") @@ -5737,10 +5742,10 @@ func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) { require.Len(t, teamPoliciesByID, 2) require.Equal(t, teamPoliciesByID[teamPolicies[0].ID], teamPolicies[0]) require.Equal(t, teamPoliciesByID[teamPolicies[1].ID], teamPolicies[1]) - c, err = ds.CountMergedTeamPolicies(ctx, team2.ID, "") + c, err = ds.CountMergedTeamPolicies(ctx, team2.ID, "", "") require.NoError(t, err) require.Equal(t, 4, c) - c, err = ds.CountPolicies(ctx, &team2.ID, "") + c, err = ds.CountPolicies(ctx, &team2.ID, "", "") require.NoError(t, err) require.Equal(t, 2, c) mergedTeamPolicies, err = ds.ListMergedTeamPolicies(ctx, team2.ID, fleet.ListOptions{}, "") @@ -5788,10 +5793,10 @@ func testTeamPoliciesNoTeam(t *testing.T, ds *Datastore) { require.Len(t, teamPoliciesByID, 2) require.Equal(t, teamPoliciesByID[teamPolicies[0].ID], teamPolicies[0]) require.Equal(t, teamPoliciesByID[teamPolicies[1].ID], teamPolicies[1]) - c, err = ds.CountMergedTeamPolicies(ctx, fleet.PolicyNoTeamID, "") + c, err = ds.CountMergedTeamPolicies(ctx, fleet.PolicyNoTeamID, "", "") require.NoError(t, err) require.Equal(t, 4, c) - c, err = ds.CountPolicies(ctx, ptr.Uint(fleet.PolicyNoTeamID), "") + c, err = ds.CountPolicies(ctx, ptr.Uint(fleet.PolicyNoTeamID), "", "") require.NoError(t, err) require.Equal(t, 2, c) mergedTeamPolicies, err = ds.ListMergedTeamPolicies(ctx, fleet.PolicyNoTeamID, fleet.ListOptions{}, "") @@ -7776,7 +7781,11 @@ func testTeamPolicyAutomationFilter(t *testing.T, ds *Datastore) { assert.Equal(t, teamWebhookPolicy.ID, merged[6].ID) assert.Equal(t, teamPatchPolicy.ID, merged[7].ID) - // Test filters + mergedCount, err := ds.CountMergedTeamPolicies(ctx, 0, "", "") + require.NoError(t, err) + assert.Equal(t, 8, mergedCount) + + // Test software merged, err = ds.ListMergedTeamPolicies(ctx, 0, fleet.ListOptions{ OrderKey: "name", OrderDirection: fleet.OrderAscending, @@ -7787,6 +7796,11 @@ func testTeamPolicyAutomationFilter(t *testing.T, ds *Datastore) { assert.Equal(t, teamAppStorePolicy.ID, merged[1].ID) assert.Equal(t, teamPatchPolicy.ID, merged[2].ID) + mergedCount, err = ds.CountMergedTeamPolicies(ctx, 0, "", "software") + require.NoError(t, err) + assert.Equal(t, 3, mergedCount) + + // Test scripts merged, err = ds.ListMergedTeamPolicies(ctx, 0, fleet.ListOptions{ OrderKey: "name", OrderDirection: fleet.OrderAscending, @@ -7795,6 +7809,11 @@ func testTeamPolicyAutomationFilter(t *testing.T, ds *Datastore) { require.Len(t, merged, 1) assert.Equal(t, teamScriptPolicy.ID, merged[0].ID) + mergedCount, err = ds.CountMergedTeamPolicies(ctx, 0, "", "scripts") + require.NoError(t, err) + assert.Equal(t, 1, mergedCount) + + // Test calendar merged, err = ds.ListMergedTeamPolicies(ctx, 0, fleet.ListOptions{ OrderKey: "name", OrderDirection: fleet.OrderAscending, @@ -7803,6 +7822,11 @@ func testTeamPolicyAutomationFilter(t *testing.T, ds *Datastore) { require.Len(t, merged, 1) assert.Equal(t, teamCalendarPolicy.ID, merged[0].ID) + mergedCount, err = ds.CountMergedTeamPolicies(ctx, 0, "", "calendar") + require.NoError(t, err) + assert.Equal(t, 1, mergedCount) + + // Test conditional_access merged, err = ds.ListMergedTeamPolicies(ctx, 0, fleet.ListOptions{ OrderKey: "name", OrderDirection: fleet.OrderAscending, @@ -7811,6 +7835,11 @@ func testTeamPolicyAutomationFilter(t *testing.T, ds *Datastore) { require.Len(t, merged, 1) assert.Equal(t, teamConditionalPolicy.ID, merged[0].ID) + mergedCount, err = ds.CountMergedTeamPolicies(ctx, 0, "", "conditional_access") + require.NoError(t, err) + assert.Equal(t, 1, mergedCount) + + // Test other merged, err = ds.ListMergedTeamPolicies(ctx, 0, fleet.ListOptions{ OrderKey: "name", OrderDirection: fleet.OrderAscending, @@ -7819,6 +7848,10 @@ func testTeamPolicyAutomationFilter(t *testing.T, ds *Datastore) { require.Len(t, merged, 1) assert.Equal(t, teamWebhookPolicy.ID, merged[0].ID) + mergedCount, err = ds.CountMergedTeamPolicies(ctx, 0, "", "other") + require.NoError(t, err) + assert.Equal(t, 1, mergedCount) + // Test not merged policies, _, err := ds.ListTeamPolicies(ctx, 0, fleet.ListOptions{ OrderKey: "name", @@ -7829,4 +7862,8 @@ func testTeamPolicyAutomationFilter(t *testing.T, ds *Datastore) { assert.Equal(t, teamInstallerPolicy.ID, policies[0].ID) assert.Equal(t, teamAppStorePolicy.ID, policies[1].ID) assert.Equal(t, teamPatchPolicy.ID, policies[2].ID) + + mergedCount, err = ds.CountPolicies(ctx, ptr.Uint(0), "", "software") + require.NoError(t, err) + assert.Equal(t, 3, mergedCount) } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 3bfea59ef79..2a49e18f044 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -834,8 +834,8 @@ type Datastore interface { ListGlobalPolicies(ctx context.Context, opts ListOptions) ([]*Policy, error) PoliciesByID(ctx context.Context, ids []uint) (map[uint]*Policy, error) DeleteGlobalPolicies(ctx context.Context, ids []uint) ([]uint, error) - CountPolicies(ctx context.Context, teamID *uint, matchQuery string) (int, error) - CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string) (int, error) + CountPolicies(ctx context.Context, teamID *uint, matchQuery string, automationType string) (int, error) + CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string, automationType string) (int, error) UpdateHostPolicyCounts(ctx context.Context) error PolicyQueriesForHost(ctx context.Context, host *Host) (map[string]string, error) @@ -911,8 +911,8 @@ type Datastore interface { // Team Policies NewTeamPolicy(ctx context.Context, teamID uint, authorID *uint, args PolicyPayload) (*Policy, error) - ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions, automationFilter string) (teamPolicies, inheritedPolicies []*Policy, err error) - ListMergedTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, automationFilter string) ([]*Policy, error) + ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions, automationType string) (teamPolicies, inheritedPolicies []*Policy, err error) + ListMergedTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, automationType string) ([]*Policy, error) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) TeamPolicy(ctx context.Context, teamID uint, policyID uint) (*Policy, error) diff --git a/server/fleet/service.go b/server/fleet/service.go index a97a6c62de7..5c6e1141982 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -833,11 +833,11 @@ type Service interface { // Team Policies NewTeamPolicy(ctx context.Context, teamID uint, p NewTeamPolicyPayload) (*Policy, error) - ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions, mergeInherited bool, automationFilter string) (teamPolicies, inheritedPolicies []*Policy, err error) + ListTeamPolicies(ctx context.Context, teamID uint, opts ListOptions, iopts ListOptions, mergeInherited bool, automationType string) (teamPolicies, inheritedPolicies []*Policy, err error) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) ModifyTeamPolicy(ctx context.Context, teamID uint, id uint, p ModifyPolicyPayload) (*Policy, error) GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, policyID uint) (*Policy, error) - CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool) (int, int, error) + CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool, automationType string) (int, int, error) // ///////////////////////////////////////////////////////////////////////////// // Geolocation diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 04b4800b1a7..3ec76327d5e 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -625,9 +625,9 @@ type PoliciesByIDFunc func(ctx context.Context, ids []uint) (map[uint]*fleet.Pol type DeleteGlobalPoliciesFunc func(ctx context.Context, ids []uint) ([]uint, error) -type CountPoliciesFunc func(ctx context.Context, teamID *uint, matchQuery string) (int, error) +type CountPoliciesFunc func(ctx context.Context, teamID *uint, matchQuery string, automationType string) (int, error) -type CountMergedTeamPoliciesFunc func(ctx context.Context, teamID uint, matchQuery string) (int, error) +type CountMergedTeamPoliciesFunc func(ctx context.Context, teamID uint, matchQuery string, automationType string) (int, error) type UpdateHostPolicyCountsFunc func(ctx context.Context) error @@ -697,9 +697,9 @@ type ListOutOfDateCalendarEventsFunc func(ctx context.Context, t time.Time) ([]* type NewTeamPolicyFunc func(ctx context.Context, teamID uint, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) -type ListTeamPoliciesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) +type ListTeamPoliciesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationType string) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) -type ListMergedTeamPoliciesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, error) +type ListMergedTeamPoliciesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions, automationType string) ([]*fleet.Policy, error) type DeleteTeamPoliciesFunc func(ctx context.Context, teamID uint, ids []uint) ([]uint, error) @@ -6654,18 +6654,18 @@ func (s *DataStore) DeleteGlobalPolicies(ctx context.Context, ids []uint) ([]uin return s.DeleteGlobalPoliciesFunc(ctx, ids) } -func (s *DataStore) CountPolicies(ctx context.Context, teamID *uint, matchQuery string) (int, error) { +func (s *DataStore) CountPolicies(ctx context.Context, teamID *uint, matchQuery string, automationType string) (int, error) { s.mu.Lock() s.CountPoliciesFuncInvoked = true s.mu.Unlock() - return s.CountPoliciesFunc(ctx, teamID, matchQuery) + return s.CountPoliciesFunc(ctx, teamID, matchQuery, automationType) } -func (s *DataStore) CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string) (int, error) { +func (s *DataStore) CountMergedTeamPolicies(ctx context.Context, teamID uint, matchQuery string, automationType string) (int, error) { s.mu.Lock() s.CountMergedTeamPoliciesFuncInvoked = true s.mu.Unlock() - return s.CountMergedTeamPoliciesFunc(ctx, teamID, matchQuery) + return s.CountMergedTeamPoliciesFunc(ctx, teamID, matchQuery, automationType) } func (s *DataStore) UpdateHostPolicyCounts(ctx context.Context) error { @@ -6906,18 +6906,18 @@ func (s *DataStore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *ui return s.NewTeamPolicyFunc(ctx, teamID, authorID, args) } -func (s *DataStore) ListTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationFilter string) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { +func (s *DataStore) ListTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, automationType string) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { s.mu.Lock() s.ListTeamPoliciesFuncInvoked = true s.mu.Unlock() - return s.ListTeamPoliciesFunc(ctx, teamID, opts, iopts, automationFilter) + return s.ListTeamPoliciesFunc(ctx, teamID, opts, iopts, automationType) } -func (s *DataStore) ListMergedTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, automationFilter string) ([]*fleet.Policy, error) { +func (s *DataStore) ListMergedTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, automationType string) ([]*fleet.Policy, error) { s.mu.Lock() s.ListMergedTeamPoliciesFuncInvoked = true s.mu.Unlock() - return s.ListMergedTeamPoliciesFunc(ctx, teamID, opts, automationFilter) + return s.ListMergedTeamPoliciesFunc(ctx, teamID, opts, automationType) } func (s *DataStore) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) { diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 6166530f8e6..8f72aba9b8e 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -513,7 +513,7 @@ type ListSoftwareByCVEFunc func(ctx context.Context, cve string, teamID *uint) ( type NewTeamPolicyFunc func(ctx context.Context, teamID uint, p fleet.NewTeamPolicyPayload) (*fleet.Policy, error) -type ListTeamPoliciesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, mergeInherited bool, automationFilter string) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) +type ListTeamPoliciesFunc func(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, mergeInherited bool, automationType string) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) type DeleteTeamPoliciesFunc func(ctx context.Context, teamID uint, ids []uint) ([]uint, error) @@ -521,7 +521,7 @@ type ModifyTeamPolicyFunc func(ctx context.Context, teamID uint, id uint, p flee type GetTeamPolicyByIDQueriesFunc func(ctx context.Context, teamID uint, policyID uint) (*fleet.Policy, error) -type CountTeamPoliciesFunc func(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool) (int, int, error) +type CountTeamPoliciesFunc func(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool, automationType string) (int, int, error) type LookupGeoIPFunc func(ctx context.Context, ip string) *fleet.GeoLocation @@ -3942,11 +3942,11 @@ func (s *Service) NewTeamPolicy(ctx context.Context, teamID uint, p fleet.NewTea return s.NewTeamPolicyFunc(ctx, teamID, p) } -func (s *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, mergeInherited bool, automationFilter string) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { +func (s *Service) ListTeamPolicies(ctx context.Context, teamID uint, opts fleet.ListOptions, iopts fleet.ListOptions, mergeInherited bool, automationType string) (teamPolicies []*fleet.Policy, inheritedPolicies []*fleet.Policy, err error) { s.mu.Lock() s.ListTeamPoliciesFuncInvoked = true s.mu.Unlock() - return s.ListTeamPoliciesFunc(ctx, teamID, opts, iopts, mergeInherited, automationFilter) + return s.ListTeamPoliciesFunc(ctx, teamID, opts, iopts, mergeInherited, automationType) } func (s *Service) DeleteTeamPolicies(ctx context.Context, teamID uint, ids []uint) ([]uint, error) { @@ -3970,11 +3970,11 @@ func (s *Service) GetTeamPolicyByIDQueries(ctx context.Context, teamID uint, pol return s.GetTeamPolicyByIDQueriesFunc(ctx, teamID, policyID) } -func (s *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool) (int, int, error) { +func (s *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool, automationType string) (int, int, error) { s.mu.Lock() s.CountTeamPoliciesFuncInvoked = true s.mu.Unlock() - return s.CountTeamPoliciesFunc(ctx, teamID, matchQuery, mergeInherited) + return s.CountTeamPoliciesFunc(ctx, teamID, matchQuery, mergeInherited, automationType) } func (s *Service) LookupGeoIP(ctx context.Context, ip string) *fleet.GeoLocation { diff --git a/server/service/global_policies.go b/server/service/global_policies.go index aab99440a30..dbc1f9c9fea 100644 --- a/server/service/global_policies.go +++ b/server/service/global_policies.go @@ -206,7 +206,7 @@ func (svc Service) CountGlobalPolicies(ctx context.Context, matchQuery string) ( return 0, err } - count, err := svc.ds.CountPolicies(ctx, nil, matchQuery) + count, err := svc.ds.CountPolicies(ctx, nil, matchQuery, "") if err != nil { return 0, err } diff --git a/server/service/team_policies.go b/server/service/team_policies.go index 5b7d84b937d..ae92e3d96d3 100644 --- a/server/service/team_policies.go +++ b/server/service/team_policies.go @@ -339,6 +339,7 @@ type countTeamPoliciesRequest struct { ListOptions fleet.ListOptions `url:"list_options"` TeamID uint `url:"fleet_id"` MergeInherited bool `query:"merge_inherited,optional"` + AutomationType string `query:"automation_type,optional"` } type countTeamPoliciesResponse struct { @@ -351,14 +352,14 @@ func (r countTeamPoliciesResponse) Error() error { return r.Err } func countTeamPoliciesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*countTeamPoliciesRequest) - count, inheritedCount, err := svc.CountTeamPolicies(ctx, req.TeamID, req.ListOptions.MatchQuery, req.MergeInherited) + count, inheritedCount, err := svc.CountTeamPolicies(ctx, req.TeamID, req.ListOptions.MatchQuery, req.MergeInherited, req.AutomationType) if err != nil { return countTeamPoliciesResponse{Err: err}, nil } return countTeamPoliciesResponse{Count: count, InheritedPolicyCount: inheritedCount}, nil } -func (svc *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool) (int, int, error) { +func (svc *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQuery string, mergeInherited bool, automationType string) (int, int, error) { if err := svc.authz.Authorize(ctx, &fleet.Policy{ PolicyData: fleet.PolicyData{ TeamID: ptr.Uint(teamID), @@ -374,18 +375,18 @@ func (svc *Service) CountTeamPolicies(ctx context.Context, teamID uint, matchQue } if mergeInherited { - count, err := svc.ds.CountMergedTeamPolicies(ctx, teamID, matchQuery) + count, err := svc.ds.CountMergedTeamPolicies(ctx, teamID, matchQuery, automationType) if err != nil { return 0, 0, err } - inheritedCount, err := svc.ds.CountPolicies(ctx, nil, matchQuery) + inheritedCount, err := svc.ds.CountPolicies(ctx, nil, matchQuery, automationType) if err != nil { return 0, 0, err } return count, inheritedCount, nil } - count, err := svc.ds.CountPolicies(ctx, &teamID, matchQuery) + count, err := svc.ds.CountPolicies(ctx, &teamID, matchQuery, automationType) if err != nil { return 0, 0, err } From 18fab4083dcdcf1268d428edd170a9436deef453 Mon Sep 17 00:00:00 2001 From: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:53:03 -0400 Subject: [PATCH 032/141] Add certificate authority (CA): Clarify instructions are for NDES (#41415) For the following quick win: - https://github.com/fleetdm/fleet/issues/41305 --- .../CertificateAuthorities/components/NDESForm/NDESForm.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/NDESForm/NDESForm.tsx b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/NDESForm/NDESForm.tsx index 0a79953c489..81ecb34a01f 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/NDESForm/NDESForm.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/NDESForm/NDESForm.tsx @@ -88,7 +88,8 @@ const NDESForm = ({ onChange={onInputChange} parseTarget placeholder="username@example.microsoft.com" - helpText="For NDES, this is the username in the down-level logon name format required to log in to the SCEP admin page." + helpText="For NDES, this is the username in the down-level logon name + format required to log in to the SCEP admin page. Okta generates this for you." /> For NDES, the password required to log in to the{" "} - Network Device Enrollment Service page. + Network Device Enrollment Service page. Okta generates this + for you. } /> From 177d7447e5d2cac4b46fbc23d81771b4ba0d08ab Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:51:23 -0500 Subject: [PATCH 033/141] Update Alex Mitchell's title to Head of Strategic Growth (#42064) ## Summary - Updates Alex Mitchell's title from "Head of Account Management" to "Head of Strategic Growth" in the Sales team table (`handbook/sales/README.md`). --- Built for [Isabell Reedy](https://fleetdm.slack.com/archives/D0AEGJCGJR0/p1773931391581339) by [Kilo for Slack](https://kilo.ai/features/slack-integration) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- handbook/sales/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handbook/sales/README.md b/handbook/sales/README.md index 62a8706d587..d9fbacb7218 100644 --- a/handbook/sales/README.md +++ b/handbook/sales/README.md @@ -8,7 +8,7 @@ This handbook page details processes specific to working [with](#contact-us) and | Role Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β  | Contributor(s) | |:--------------------------------------|:------------------------------------------------------------------------------------------| | SVP of Global Sales | [Chaz MacLaughlin](https://www.linkedin.com/in/chazmaclaughlin/) _([@chazmac6](https://github.com/chazmac6))_ -| Head of Account Management | [Alex Mitchell](https://www.linkedin.com/in/alexandercmitchell/) _([@alexmitchelliii](https://github.com/alexmitchelliii))_ +| Head of Strategic Growth | [Alex Mitchell](https://www.linkedin.com/in/alexandercmitchell/) _([@alexmitchelliii](https://github.com/alexmitchelliii))_ | Channel Manager, North America | [Eric Comeau](https://www.linkedin.com/in/escomeau/) _([@escomeau](https://github.com/escomeau))_ | Account Executive (AE) | _See [πŸš‚ Go-To-Market operations](https://fleetdm.com/handbook/company/go-to-market-operations#current-gtm-motions)_ | Solutions Specialist | _See [πŸš‚ Go-To-Market operations](https://fleetdm.com/handbook/company/go-to-market-operations#current-gtm-motions)_ From 945d74b0d37946798dd5a2f334abee7756ef0725 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 19 Mar 2026 10:27:50 -0500 Subject: [PATCH 034/141] Website: Remove duplicate article card & update incorrect/duplicated card titles on /customers (#42072) Changes: - Updated the card links to case studies on /customers --- website/views/pages/testimonials.ejs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/website/views/pages/testimonials.ejs b/website/views/pages/testimonials.ejs index d34ca579c5a..cc61c5121e4 100644 --- a/website/views/pages/testimonials.ejs +++ b/website/views/pages/testimonials.ejs @@ -148,11 +148,6 @@

Agritech producer replaces manual tracking across 273 devices.

Read their story
-
-

Agritech producer

-

Agritech producer replaces manual tracking across 273 devices.

- Read their story -

AI security company

AI security company runs live queries to verify CVEs in seconds.

@@ -274,7 +269,7 @@ Read their story
-

Global technology platform

+

Global entertainment company

Global entertainment company manages thousands of devices with Fleet.

Read their story
@@ -304,7 +299,7 @@ Read their story
-

Healthcare technology organization

+

Identity platform

An identity platform improves Linux visibility.

Read their story
@@ -334,12 +329,12 @@ Read their story
-

Online gaming platform

+

Medical research institution

A medical research institution brings Linux devices into compliance.

Read their story
-

Online gaming platform

+

National research organization

A research organization uses Fleet to automate Linux patching and improve device visibility.

Read their story
@@ -354,7 +349,7 @@ Read their story
-

Online gaming platform

+

Online marketplace

An online marketplace replaces complex device tools.

Read their story
@@ -379,7 +374,7 @@ Read their story
-

Robotics company

+

Technology platform

Technology company manages 15,000 iPads with Fleet.

Read their story
From a59a9c8bb2fe2a676e6e58c8c35aed7bede297bf Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:01:01 -0500 Subject: [PATCH 035/141] Re-timestamp migrations after 4.82.1 (#42058) --- ...ceToApps.go => 20260316120001_MigratePkgSourceToApps.go} | 6 +++--- ...est.go => 20260316120001_MigratePkgSourceToApps_test.go} | 2 +- ...les.go => 20260316120002_FixMismatchedSoftwareTitles.go} | 6 +++--- ...o => 20260316120002_FixMismatchedSoftwareTitles_test.go} | 2 +- ... => 20260316120003_CleanupSoftwareHostCountsZeroRows.go} | 6 +++--- ...0260316120004_FixUnverifiedSuccessfulWindowsProfiles.go} | 6 +++--- ...20260316120005_OptimizeSoftwareTitlesHostCountsIndex.go} | 6 +++--- ...316120006_AddLockEndUserInfoToAppConfigAndTeamConfig.go} | 6 +++--- ...0006_AddLockEndUserInfoToAppConfigAndTeamConfig_test.go} | 4 ++-- ...go => 20260316120007_RemoveBypassEnabledFromPolicies.go} | 6 +++--- ... 20260316120007_RemoveBypassEnabledFromPolicies_test.go} | 2 +- ....go => 20260316120008_RenameActivitiesToActivityPast.go} | 6 +++--- ...> 20260316120009_CreateHostRecoveryKeyPasswordsTable.go} | 6 +++--- ...cyColumns.go => 20260316120010_AddPatchPolicyColumns.go} | 6 +++--- ...test.go => 20260316120010_AddPatchPolicyColumns_test.go} | 2 +- ...> 20260316120011_AddPolicyNeedsFullMembershipCleanup.go} | 6 +++--- server/datastore/mysql/schema.sql | 2 +- 17 files changed, 40 insertions(+), 40 deletions(-) rename server/datastore/mysql/migrations/tables/{20260218175705_MigratePkgSourceToApps.go => 20260316120001_MigratePkgSourceToApps.go} (66%) rename server/datastore/mysql/migrations/tables/{20260218175705_MigratePkgSourceToApps_test.go => 20260316120001_MigratePkgSourceToApps_test.go} (97%) rename server/datastore/mysql/migrations/tables/{20260218175706_FixMismatchedSoftwareTitles.go => 20260316120002_FixMismatchedSoftwareTitles.go} (96%) rename server/datastore/mysql/migrations/tables/{20260218175706_FixMismatchedSoftwareTitles_test.go => 20260316120002_FixMismatchedSoftwareTitles_test.go} (98%) rename server/datastore/mysql/migrations/tables/{20260223000000_CleanupSoftwareHostCountsZeroRows.go => 20260316120003_CleanupSoftwareHostCountsZeroRows.go} (88%) rename server/datastore/mysql/migrations/tables/{20260225143121_FixUnverifiedSuccessfulWindowsProfiles.go => 20260316120004_FixUnverifiedSuccessfulWindowsProfiles.go} (82%) rename server/datastore/mysql/migrations/tables/{20260226182000_OptimizeSoftwareTitlesHostCountsIndex.go => 20260316120005_OptimizeSoftwareTitlesHostCountsIndex.go} (84%) rename server/datastore/mysql/migrations/tables/{20260228115022_AddLockEndUserInfoToAppConfigAndTeamConfig.go => 20260316120006_AddLockEndUserInfoToAppConfigAndTeamConfig.go} (91%) rename server/datastore/mysql/migrations/tables/{20260228115022_AddLockEndUserInfoToAppConfigAndTeamConfig_test.go => 20260316120006_AddLockEndUserInfoToAppConfigAndTeamConfig_test.go} (97%) rename server/datastore/mysql/migrations/tables/{20260303180102_RemoveBypassEnabledFromPolicies.go => 20260316120007_RemoveBypassEnabledFromPolicies.go} (89%) rename server/datastore/mysql/migrations/tables/{20260303180102_RemoveBypassEnabledFromPolicies_test.go => 20260316120007_RemoveBypassEnabledFromPolicies_test.go} (99%) rename server/datastore/mysql/migrations/tables/{20260306120000_RenameActivitiesToActivityPast.go => 20260316120008_RenameActivitiesToActivityPast.go} (63%) rename server/datastore/mysql/migrations/tables/{20260311160000_CreateHostRecoveryKeyPasswordsTable.go => 20260316120009_CreateHostRecoveryKeyPasswordsTable.go} (87%) rename server/datastore/mysql/migrations/tables/{20260313173516_AddPatchPolicyColumns.go => 20260316120010_AddPatchPolicyColumns.go} (85%) rename server/datastore/mysql/migrations/tables/{20260313173516_AddPatchPolicyColumns_test.go => 20260316120010_AddPatchPolicyColumns_test.go} (98%) rename server/datastore/mysql/migrations/tables/{20260316000001_AddPolicyNeedsFullMembershipCleanup.go => 20260316120011_AddPolicyNeedsFullMembershipCleanup.go} (61%) diff --git a/server/datastore/mysql/migrations/tables/20260218175705_MigratePkgSourceToApps.go b/server/datastore/mysql/migrations/tables/20260316120001_MigratePkgSourceToApps.go similarity index 66% rename from server/datastore/mysql/migrations/tables/20260218175705_MigratePkgSourceToApps.go rename to server/datastore/mysql/migrations/tables/20260316120001_MigratePkgSourceToApps.go index f13d96a1630..9a44c6a83e4 100644 --- a/server/datastore/mysql/migrations/tables/20260218175705_MigratePkgSourceToApps.go +++ b/server/datastore/mysql/migrations/tables/20260316120001_MigratePkgSourceToApps.go @@ -6,10 +6,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260218175705, Down_20260218175705) + MigrationClient.AddMigration(Up_20260316120001, Down_20260316120001) } -func Up_20260218175705(tx *sql.Tx) error { +func Up_20260316120001(tx *sql.Tx) error { _, err := tx.Exec(`UPDATE software_titles SET source = 'apps' WHERE source = 'pkg_packages' AND bundle_identifier != ''`) if err != nil { return fmt.Errorf("failed to change source for software titles: %w", err) @@ -17,6 +17,6 @@ func Up_20260218175705(tx *sql.Tx) error { return nil } -func Down_20260218175705(tx *sql.Tx) error { +func Down_20260316120001(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260218175705_MigratePkgSourceToApps_test.go b/server/datastore/mysql/migrations/tables/20260316120001_MigratePkgSourceToApps_test.go similarity index 97% rename from server/datastore/mysql/migrations/tables/20260218175705_MigratePkgSourceToApps_test.go rename to server/datastore/mysql/migrations/tables/20260316120001_MigratePkgSourceToApps_test.go index b90074b7c22..57d8af112f1 100644 --- a/server/datastore/mysql/migrations/tables/20260218175705_MigratePkgSourceToApps_test.go +++ b/server/datastore/mysql/migrations/tables/20260316120001_MigratePkgSourceToApps_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestUp_20260218175705(t *testing.T) { +func TestUp_20260316120001(t *testing.T) { db := applyUpToPrev(t) unaffectedTitleID := execNoErrLastID(t, db, ` diff --git a/server/datastore/mysql/migrations/tables/20260218175706_FixMismatchedSoftwareTitles.go b/server/datastore/mysql/migrations/tables/20260316120002_FixMismatchedSoftwareTitles.go similarity index 96% rename from server/datastore/mysql/migrations/tables/20260218175706_FixMismatchedSoftwareTitles.go rename to server/datastore/mysql/migrations/tables/20260316120002_FixMismatchedSoftwareTitles.go index a232df7ef08..562459e491a 100644 --- a/server/datastore/mysql/migrations/tables/20260218175706_FixMismatchedSoftwareTitles.go +++ b/server/datastore/mysql/migrations/tables/20260316120002_FixMismatchedSoftwareTitles.go @@ -9,10 +9,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260218175706, Down_20260218175706) + MigrationClient.AddMigration(Up_20260316120002, Down_20260316120002) } -func Up_20260218175706(tx *sql.Tx) error { +func Up_20260316120002(tx *sql.Tx) error { txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)} // find and fix mismatched software installers @@ -130,6 +130,6 @@ func getOrInsertTitleID(txx sqlx.Tx, name, bundleIdentifier, source string) (uin return titleID, nil } -func Down_20260218175706(tx *sql.Tx) error { +func Down_20260316120002(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260218175706_FixMismatchedSoftwareTitles_test.go b/server/datastore/mysql/migrations/tables/20260316120002_FixMismatchedSoftwareTitles_test.go similarity index 98% rename from server/datastore/mysql/migrations/tables/20260218175706_FixMismatchedSoftwareTitles_test.go rename to server/datastore/mysql/migrations/tables/20260316120002_FixMismatchedSoftwareTitles_test.go index 0afd3256ff8..2ad4a65ce96 100644 --- a/server/datastore/mysql/migrations/tables/20260218175706_FixMismatchedSoftwareTitles_test.go +++ b/server/datastore/mysql/migrations/tables/20260316120002_FixMismatchedSoftwareTitles_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestUp_20260218175706(t *testing.T) { +func TestUp_20260316120002(t *testing.T) { db := applyUpToPrev(t) // Test 1 - mismatched software, no existing title with correct source diff --git a/server/datastore/mysql/migrations/tables/20260223000000_CleanupSoftwareHostCountsZeroRows.go b/server/datastore/mysql/migrations/tables/20260316120003_CleanupSoftwareHostCountsZeroRows.go similarity index 88% rename from server/datastore/mysql/migrations/tables/20260223000000_CleanupSoftwareHostCountsZeroRows.go rename to server/datastore/mysql/migrations/tables/20260316120003_CleanupSoftwareHostCountsZeroRows.go index 9c2bdfe0755..64e3ee01707 100644 --- a/server/datastore/mysql/migrations/tables/20260223000000_CleanupSoftwareHostCountsZeroRows.go +++ b/server/datastore/mysql/migrations/tables/20260316120003_CleanupSoftwareHostCountsZeroRows.go @@ -5,10 +5,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260223000000, Down_20260223000000) + MigrationClient.AddMigration(Up_20260316120003, Down_20260316120003) } -func Up_20260223000000(tx *sql.Tx) error { +func Up_20260316120003(tx *sql.Tx) error { // Delete any accumulated zero-count rows from software_host_counts and software_titles_host_counts. // After this migration, the sync process uses an atomic swap table pattern that never produces zero-count rows. // Add CHECK constraints to prevent zero-count rows from being inserted in the future. @@ -35,6 +35,6 @@ func Up_20260223000000(tx *sql.Tx) error { }, tx) } -func Down_20260223000000(tx *sql.Tx) error { +func Down_20260316120003(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260225143121_FixUnverifiedSuccessfulWindowsProfiles.go b/server/datastore/mysql/migrations/tables/20260316120004_FixUnverifiedSuccessfulWindowsProfiles.go similarity index 82% rename from server/datastore/mysql/migrations/tables/20260225143121_FixUnverifiedSuccessfulWindowsProfiles.go rename to server/datastore/mysql/migrations/tables/20260316120004_FixUnverifiedSuccessfulWindowsProfiles.go index fb0a4204b60..638388963af 100644 --- a/server/datastore/mysql/migrations/tables/20260225143121_FixUnverifiedSuccessfulWindowsProfiles.go +++ b/server/datastore/mysql/migrations/tables/20260316120004_FixUnverifiedSuccessfulWindowsProfiles.go @@ -7,10 +7,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260225143121, Down_20260225143121) + MigrationClient.AddMigration(Up_20260316120004, Down_20260316120004) } -func Up_20260225143121(tx *sql.Tx) error { +func Up_20260316120004(tx *sql.Tx) error { return withSteps([]migrationStep{ basicMigrationStepWithArgs( "UPDATE host_mdm_windows_profiles SET status = ? WHERE status = ?", @@ -25,6 +25,6 @@ func Up_20260225143121(tx *sql.Tx) error { }, tx) } -func Down_20260225143121(tx *sql.Tx) error { +func Down_20260316120004(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260226182000_OptimizeSoftwareTitlesHostCountsIndex.go b/server/datastore/mysql/migrations/tables/20260316120005_OptimizeSoftwareTitlesHostCountsIndex.go similarity index 84% rename from server/datastore/mysql/migrations/tables/20260226182000_OptimizeSoftwareTitlesHostCountsIndex.go rename to server/datastore/mysql/migrations/tables/20260316120005_OptimizeSoftwareTitlesHostCountsIndex.go index b476f8eddda..5fd7046daad 100644 --- a/server/datastore/mysql/migrations/tables/20260226182000_OptimizeSoftwareTitlesHostCountsIndex.go +++ b/server/datastore/mysql/migrations/tables/20260316120005_OptimizeSoftwareTitlesHostCountsIndex.go @@ -6,10 +6,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260226182000, Down_20260226182000) + MigrationClient.AddMigration(Up_20260316120005, Down_20260316120005) } -func Up_20260226182000(tx *sql.Tx) error { +func Up_20260316120005(tx *sql.Tx) error { // Drop the old index that doesn't include global_stats or DESC on hosts_count _, err := tx.Exec(` DROP INDEX idx_software_titles_host_counts_team_counts_title @@ -34,6 +34,6 @@ func Up_20260226182000(tx *sql.Tx) error { return nil } -func Down_20260226182000(_ *sql.Tx) error { +func Down_20260316120005(_ *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260228115022_AddLockEndUserInfoToAppConfigAndTeamConfig.go b/server/datastore/mysql/migrations/tables/20260316120006_AddLockEndUserInfoToAppConfigAndTeamConfig.go similarity index 91% rename from server/datastore/mysql/migrations/tables/20260228115022_AddLockEndUserInfoToAppConfigAndTeamConfig.go rename to server/datastore/mysql/migrations/tables/20260316120006_AddLockEndUserInfoToAppConfigAndTeamConfig.go index 487ac95d9ca..891eaa7a723 100644 --- a/server/datastore/mysql/migrations/tables/20260228115022_AddLockEndUserInfoToAppConfigAndTeamConfig.go +++ b/server/datastore/mysql/migrations/tables/20260316120006_AddLockEndUserInfoToAppConfigAndTeamConfig.go @@ -10,10 +10,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260228115022, Down_20260228115022) + MigrationClient.AddMigration(Up_20260316120006, Down_20260316120006) } -func Up_20260228115022(tx *sql.Tx) error { +func Up_20260316120006(tx *sql.Tx) error { // Update app config to set this new value to true if End User Auth is enabled err := updateAppConfigJSON(tx, func(config *fleet.AppConfig) error { if config != nil { @@ -72,6 +72,6 @@ func Up_20260228115022(tx *sql.Tx) error { return nil } -func Down_20260228115022(tx *sql.Tx) error { +func Down_20260316120006(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260228115022_AddLockEndUserInfoToAppConfigAndTeamConfig_test.go b/server/datastore/mysql/migrations/tables/20260316120006_AddLockEndUserInfoToAppConfigAndTeamConfig_test.go similarity index 97% rename from server/datastore/mysql/migrations/tables/20260228115022_AddLockEndUserInfoToAppConfigAndTeamConfig_test.go rename to server/datastore/mysql/migrations/tables/20260316120006_AddLockEndUserInfoToAppConfigAndTeamConfig_test.go index 40396991228..156cc71bb54 100644 --- a/server/datastore/mysql/migrations/tables/20260228115022_AddLockEndUserInfoToAppConfigAndTeamConfig_test.go +++ b/server/datastore/mysql/migrations/tables/20260316120006_AddLockEndUserInfoToAppConfigAndTeamConfig_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestUp_20260228115022(t *testing.T) { +func TestUp_20260316120006(t *testing.T) { db := applyUpToPrev(t) // Setup AppConfig @@ -79,7 +79,7 @@ func TestUp_20260228115022(t *testing.T) { require.False(t, finalTeamConfig2.MDM.MacOSSetup.LockEndUserInfo.Value) } -func TestUp_20260228115022_AppConfigEUADisabled(t *testing.T) { +func TestUp_20260316120006_AppConfigEUADisabled(t *testing.T) { db := applyUpToPrev(t) // Setup AppConfig diff --git a/server/datastore/mysql/migrations/tables/20260303180102_RemoveBypassEnabledFromPolicies.go b/server/datastore/mysql/migrations/tables/20260316120007_RemoveBypassEnabledFromPolicies.go similarity index 89% rename from server/datastore/mysql/migrations/tables/20260303180102_RemoveBypassEnabledFromPolicies.go rename to server/datastore/mysql/migrations/tables/20260316120007_RemoveBypassEnabledFromPolicies.go index 9bf25a70349..406d26e9acf 100644 --- a/server/datastore/mysql/migrations/tables/20260303180102_RemoveBypassEnabledFromPolicies.go +++ b/server/datastore/mysql/migrations/tables/20260316120007_RemoveBypassEnabledFromPolicies.go @@ -6,10 +6,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260303180102, Down_20260303180102) + MigrationClient.AddMigration(Up_20260316120007, Down_20260316120007) } -func Up_20260303180102(tx *sql.Tx) error { +func Up_20260316120007(tx *sql.Tx) error { // Promote bypass=true policies to critical for teams that have Okta conditional // access enabled, but only when Okta is globally configured. _, err := tx.Exec(` @@ -39,6 +39,6 @@ func Up_20260303180102(tx *sql.Tx) error { return nil } -func Down_20260303180102(tx *sql.Tx) error { +func Down_20260316120007(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260303180102_RemoveBypassEnabledFromPolicies_test.go b/server/datastore/mysql/migrations/tables/20260316120007_RemoveBypassEnabledFromPolicies_test.go similarity index 99% rename from server/datastore/mysql/migrations/tables/20260303180102_RemoveBypassEnabledFromPolicies_test.go rename to server/datastore/mysql/migrations/tables/20260316120007_RemoveBypassEnabledFromPolicies_test.go index e5485484ef2..9598bb7698f 100644 --- a/server/datastore/mysql/migrations/tables/20260303180102_RemoveBypassEnabledFromPolicies_test.go +++ b/server/datastore/mysql/migrations/tables/20260316120007_RemoveBypassEnabledFromPolicies_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestUp_20260303180102(t *testing.T) { +func TestUp_20260316120007(t *testing.T) { // setOktaConfig sets or clears Okta conditional access config in app_config_json. setOktaConfig := func(t *testing.T, db *sqlx.DB, configured bool) { t.Helper() diff --git a/server/datastore/mysql/migrations/tables/20260306120000_RenameActivitiesToActivityPast.go b/server/datastore/mysql/migrations/tables/20260316120008_RenameActivitiesToActivityPast.go similarity index 63% rename from server/datastore/mysql/migrations/tables/20260306120000_RenameActivitiesToActivityPast.go rename to server/datastore/mysql/migrations/tables/20260316120008_RenameActivitiesToActivityPast.go index eb7cb0e6a6d..ed951d3d9aa 100644 --- a/server/datastore/mysql/migrations/tables/20260306120000_RenameActivitiesToActivityPast.go +++ b/server/datastore/mysql/migrations/tables/20260316120008_RenameActivitiesToActivityPast.go @@ -6,10 +6,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260306120000, Down_20260306120000) + MigrationClient.AddMigration(Up_20260316120008, Down_20260316120008) } -func Up_20260306120000(tx *sql.Tx) error { +func Up_20260316120008(tx *sql.Tx) error { _, err := tx.Exec(`RENAME TABLE activities TO activity_past, host_activities TO activity_host_past`) if err != nil { return fmt.Errorf("rename activities tables: %w", err) @@ -17,6 +17,6 @@ func Up_20260306120000(tx *sql.Tx) error { return nil } -func Down_20260306120000(tx *sql.Tx) error { +func Down_20260316120008(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260311160000_CreateHostRecoveryKeyPasswordsTable.go b/server/datastore/mysql/migrations/tables/20260316120009_CreateHostRecoveryKeyPasswordsTable.go similarity index 87% rename from server/datastore/mysql/migrations/tables/20260311160000_CreateHostRecoveryKeyPasswordsTable.go rename to server/datastore/mysql/migrations/tables/20260316120009_CreateHostRecoveryKeyPasswordsTable.go index 42f496fe658..c4cec77c1e8 100644 --- a/server/datastore/mysql/migrations/tables/20260311160000_CreateHostRecoveryKeyPasswordsTable.go +++ b/server/datastore/mysql/migrations/tables/20260316120009_CreateHostRecoveryKeyPasswordsTable.go @@ -6,10 +6,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260311160000, Down_20260311160000) + MigrationClient.AddMigration(Up_20260316120009, Down_20260316120009) } -func Up_20260311160000(tx *sql.Tx) error { +func Up_20260316120009(tx *sql.Tx) error { if _, err := tx.Exec(` CREATE TABLE host_recovery_key_passwords ( host_uuid varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, @@ -33,6 +33,6 @@ func Up_20260311160000(tx *sql.Tx) error { return nil } -func Down_20260311160000(tx *sql.Tx) error { +func Down_20260316120009(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260313173516_AddPatchPolicyColumns.go b/server/datastore/mysql/migrations/tables/20260316120010_AddPatchPolicyColumns.go similarity index 85% rename from server/datastore/mysql/migrations/tables/20260313173516_AddPatchPolicyColumns.go rename to server/datastore/mysql/migrations/tables/20260316120010_AddPatchPolicyColumns.go index 61ae64de3cf..76dc52e3eeb 100644 --- a/server/datastore/mysql/migrations/tables/20260313173516_AddPatchPolicyColumns.go +++ b/server/datastore/mysql/migrations/tables/20260316120010_AddPatchPolicyColumns.go @@ -5,10 +5,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260313173516, Down_20260313173516) + MigrationClient.AddMigration(Up_20260316120010, Down_20260316120010) } -func Up_20260313173516(tx *sql.Tx) error { +func Up_20260316120010(tx *sql.Tx) error { return withSteps([]migrationStep{ basicMigrationStep( `ALTER TABLE policies ADD COLUMN type ENUM('dynamic', 'patch') NOT NULL DEFAULT 'dynamic'`, @@ -30,6 +30,6 @@ func Up_20260313173516(tx *sql.Tx) error { }, tx) } -func Down_20260313173516(tx *sql.Tx) error { +func Down_20260316120010(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260313173516_AddPatchPolicyColumns_test.go b/server/datastore/mysql/migrations/tables/20260316120010_AddPatchPolicyColumns_test.go similarity index 98% rename from server/datastore/mysql/migrations/tables/20260313173516_AddPatchPolicyColumns_test.go rename to server/datastore/mysql/migrations/tables/20260316120010_AddPatchPolicyColumns_test.go index ffeaaa45a00..a0fe590a5ab 100644 --- a/server/datastore/mysql/migrations/tables/20260313173516_AddPatchPolicyColumns_test.go +++ b/server/datastore/mysql/migrations/tables/20260316120010_AddPatchPolicyColumns_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestUp_20260313173516(t *testing.T) { +func TestUp_20260316120010(t *testing.T) { db := applyUpToPrev(t) teamID := execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES (?)`, "Test Team") diff --git a/server/datastore/mysql/migrations/tables/20260316000001_AddPolicyNeedsFullMembershipCleanup.go b/server/datastore/mysql/migrations/tables/20260316120011_AddPolicyNeedsFullMembershipCleanup.go similarity index 61% rename from server/datastore/mysql/migrations/tables/20260316000001_AddPolicyNeedsFullMembershipCleanup.go rename to server/datastore/mysql/migrations/tables/20260316120011_AddPolicyNeedsFullMembershipCleanup.go index d405d9d2740..c1b2a79b654 100644 --- a/server/datastore/mysql/migrations/tables/20260316000001_AddPolicyNeedsFullMembershipCleanup.go +++ b/server/datastore/mysql/migrations/tables/20260316120011_AddPolicyNeedsFullMembershipCleanup.go @@ -3,10 +3,10 @@ package tables import "database/sql" func init() { - MigrationClient.AddMigration(Up_20260316000001, Down_20260316000001) + MigrationClient.AddMigration(Up_20260316120011, Down_20260316120011) } -func Up_20260316000001(tx *sql.Tx) error { +func Up_20260316120011(tx *sql.Tx) error { _, err := tx.Exec(` ALTER TABLE policies ADD COLUMN needs_full_membership_cleanup TINYINT(1) NOT NULL DEFAULT 0, @@ -15,6 +15,6 @@ func Up_20260316000001(tx *sql.Tx) error { return err } -func Down_20260316000001(tx *sql.Tx) error { +func Down_20260316120011(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index c5e5f1a6b47..dab42818d5c 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1806,7 +1806,7 @@ CREATE TABLE `migration_status_tables` ( PRIMARY KEY (`id`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=497 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260218175705,1,'2020-01-01 01:01:01'),(484,20260218175706,1,'2020-01-01 01:01:01'),(485,20260223000000,1,'2020-01-01 01:01:01'),(486,20260225143121,1,'2020-01-01 01:01:01'),(487,20260226182000,1,'2020-01-01 01:01:01'),(488,20260228115022,1,'2020-01-01 01:01:01'),(489,20260303180102,1,'2020-01-01 01:01:01'),(490,20260306120000,1,'2020-01-01 01:01:01'),(491,20260311160000,1,'2020-01-01 01:01:01'),(492,20260313173516,1,'2020-01-01 01:01:01'),(493,20260314120000,1,'2020-01-01 01:01:01'),(494,20260316000001,1,'2020-01-01 01:01:01'),(495,20260316120000,1,'2020-01-01 01:01:01'),(496,20260317120000,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260316120011,1,'2020-01-01 01:01:01'),(496,20260317120000,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( From 518a2982f0e4a60a01458de168907486b471f74b Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:01:43 -0500 Subject: [PATCH 036/141] Add Firefox ESR as macOS & Windows FMAs (#40089) **Related issue:** Resolves #40086 --- .../workflows/test-fma-windows-pr-only.yml | 94 +++++++++ .../inputs/homebrew/firefox@esr.json | 8 + .../inputs/homebrew/schema/input-schema.json | 2 +- .../inputs/winget/firefox@esr.json | 12 ++ .../winget/scripts/firefox_esr_install.ps1 | 31 +++ .../winget/scripts/firefox_esr_uninstall.ps1 | 88 +++++++++ ee/maintained-apps/outputs/apps.json | 14 ++ .../outputs/firefox@esr/darwin.json | 21 ++ .../outputs/firefox@esr/windows.json | 21 ++ .../SoftwarePage/components/icons/Firefox.tsx | 186 +----------------- .../images/app-icon-firefox@esr-60x60@2x.png | Bin 0 -> 14213 bytes 11 files changed, 300 insertions(+), 177 deletions(-) create mode 100644 ee/maintained-apps/inputs/homebrew/firefox@esr.json create mode 100644 ee/maintained-apps/inputs/winget/firefox@esr.json create mode 100644 ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/firefox_esr_uninstall.ps1 create mode 100644 ee/maintained-apps/outputs/firefox@esr/darwin.json create mode 100644 ee/maintained-apps/outputs/firefox@esr/windows.json create mode 100644 website/assets/images/app-icon-firefox@esr-60x60@2x.png diff --git a/.github/workflows/test-fma-windows-pr-only.yml b/.github/workflows/test-fma-windows-pr-only.yml index 155b40dae37..26e620289b7 100644 --- a/.github/workflows/test-fma-windows-pr-only.yml +++ b/.github/workflows/test-fma-windows-pr-only.yml @@ -96,6 +96,7 @@ jobs: "has_windows_apps=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "has_google_chrome=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "has_7zip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "has_firefox=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append exit 0 } @@ -107,6 +108,7 @@ jobs: "has_windows_apps=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "has_google_chrome=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "has_7zip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "has_firefox=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append Write-Host "No windows apps changed, skipping Windows workflow" } else { "has_windows_apps=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append @@ -129,6 +131,14 @@ jobs: } else { "has_7zip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append } + + # Check if firefox/windows or firefox@esr/windows is in the changed apps + if (("firefox/windows" -in $windowsSlugs) -or ("firefox@esr/windows" -in $windowsSlugs)) { + "has_firefox=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "Firefox detected in changed apps" + } else { + "has_firefox=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + } } shell: pwsh @@ -232,6 +242,90 @@ jobs: } shell: pwsh + - name: Remove pre-installed Firefox + if: steps.check-windows-apps.outputs.has_windows_apps == 'true' && steps.check-windows-apps.outputs.has_firefox == 'true' + run: | + Write-Host "Listing all installed packages containing 'Firefox':" + Get-Package | Where-Object { $_.Name -like "*Firefox*" } | ForEach-Object { + Write-Host " - $($_.Name) (Version: $($_.Version))" + } + + $uninstallPaths = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + + $found = $false + foreach ($path in $uninstallPaths) { + $entries = Get-ItemProperty $path -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "*Mozilla Firefox*" } + foreach ($entry in $entries) { + if (-not $entry) { continue } + $found = $true + Write-Host "Found Firefox: $($entry.DisplayName)" + + $uninstallString = if ($entry.QuietUninstallString) { + $entry.QuietUninstallString + } elseif ($entry.UninstallString) { + $entry.UninstallString + } else { + $null + } + + if ($uninstallString) { + Write-Host "Uninstall string: $uninstallString" + try { + $splitArgs = $uninstallString.Split('"') + if ($splitArgs.Length -ge 3) { + $exePath = $splitArgs[1] + Write-Host "Uninstalling Firefox via: $exePath /S" + Start-Process -FilePath $exePath -ArgumentList "/S" -Wait -NoNewWindow + Write-Host "Successfully removed $($entry.DisplayName)" + } else { + Write-Host "Uninstalling Firefox via: $uninstallString /S" + Start-Process -FilePath $uninstallString -ArgumentList "/S" -Wait -NoNewWindow + Write-Host "Successfully removed $($entry.DisplayName)" + } + } catch { + Write-Host "Failed to remove Firefox: $($_.Exception.Message)" + } + } else { + Write-Host "Firefox uninstall string not found in registry entry" + } + } + } + + if (-not $found) { + Write-Host "Firefox not found in registry" + } + + # Kill any lingering Firefox/Mozilla processes + Write-Host "Stopping any lingering Firefox processes..." + Get-Process -Name "firefox","plugin-container","updater","maintenanceservice*","helper" -ErrorAction SilentlyContinue | ForEach-Object { + Write-Host " Killing process: $($_.Name) (PID: $($_.Id))" + Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue + } + + Start-Sleep -Seconds 10 + + # Force-remove leftover Firefox directories from Program Files + $firefoxDirs = @( + "C:\Program Files\Mozilla Firefox", + "C:\Program Files (x86)\Mozilla Firefox", + "C:\Program Files\Mozilla Maintenance Service" + ) + foreach ($dir in $firefoxDirs) { + if (Test-Path $dir) { + Write-Host "Removing leftover directory: $dir" + Remove-Item -Path $dir -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path $dir) { + Write-Host "WARNING: Failed to fully remove $dir" + } else { + Write-Host "Removed $dir" + } + } + } + shell: pwsh + - name: Filter apps.json and verify changed apps if: steps.check-windows-apps.outputs.has_windows_apps == 'true' run: | diff --git a/ee/maintained-apps/inputs/homebrew/firefox@esr.json b/ee/maintained-apps/inputs/homebrew/firefox@esr.json new file mode 100644 index 00000000000..f76175079de --- /dev/null +++ b/ee/maintained-apps/inputs/homebrew/firefox@esr.json @@ -0,0 +1,8 @@ +{ + "name": "Mozilla Firefox ESR", + "unique_identifier": "org.mozilla.firefox", + "token": "firefox@esr", + "installer_format": "dmg", + "slug": "firefox@esr/darwin", + "default_categories": ["Browsers"] +} diff --git a/ee/maintained-apps/inputs/homebrew/schema/input-schema.json b/ee/maintained-apps/inputs/homebrew/schema/input-schema.json index 7787e370bce..57e55988c87 100644 --- a/ee/maintained-apps/inputs/homebrew/schema/input-schema.json +++ b/ee/maintained-apps/inputs/homebrew/schema/input-schema.json @@ -35,7 +35,7 @@ "slug": { "type": "string", "description": "The slug identifies a specific app and platform combination. It is used to name the manifest files that contain the metadata that Fleet needs to add, install, and uninstall this app. Format: app-name/platform (e.g., adobe-acrobat-reader/darwin)", - "pattern": "^[a-z0-9-+]+/darwin$", + "pattern": "^[a-z0-9-+@]+/darwin$", "minLength": 1 }, "pre_uninstall_scripts": { diff --git a/ee/maintained-apps/inputs/winget/firefox@esr.json b/ee/maintained-apps/inputs/winget/firefox@esr.json new file mode 100644 index 00000000000..d40c7dfbe07 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/firefox@esr.json @@ -0,0 +1,12 @@ +{ + "name": "Mozilla Firefox ESR", + "slug": "firefox@esr/windows", + "package_identifier": "Mozilla.Firefox.ESR", + "unique_identifier": "Mozilla Firefox 140.7.1 ESR (x64 en-US)", + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1", + "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/firefox_esr_uninstall.ps1", + "installer_arch": "x64", + "installer_type": "exe", + "installer_scope": "machine", + "default_categories": ["Browsers"] +} diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 new file mode 100644 index 00000000000..06a0f1fabcf --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 @@ -0,0 +1,31 @@ +# Learn more about .exe install scripts: +# http://fleetdm.com/learn-more-about/exe-install-scripts + +$exeFilePath = "${env:INSTALLER_PATH}" +$installDir = "C:\Program Files\Mozilla Firefox" +$maxWaitSeconds = 120 + +try { + +# Start silent install without -Wait; the Firefox ESR installer launches the +# browser after installing and blocks until it is closed. +Start-Process -FilePath "$exeFilePath" -ArgumentList "/S" + +# Poll for installation to complete +$elapsed = 0 +while ($elapsed -lt $maxWaitSeconds) { + Start-Sleep -Seconds 5 + $elapsed += 5 + if (Test-Path "$installDir\firefox.exe") { + Write-Host "Firefox ESR installed successfully after $elapsed seconds" + Exit 0 + } +} + +Write-Host "Timed out waiting for Firefox ESR to install" +Exit 1 + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_esr_uninstall.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_esr_uninstall.ps1 new file mode 100644 index 00000000000..90904a7da44 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_esr_uninstall.ps1 @@ -0,0 +1,88 @@ +# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID +# variable +# Match Firefox ESR only (e.g. "Mozilla Firefox 140.7.1 ESR (x64 en-US)"), not regular Firefox +$softwareNameLike = "*Firefox*ESR*" + +# NSIS installers require /S flag for silent uninstall +$uninstallArgs = "/S" + +$paths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' +) + +$exitCode = 0 + +try { + +[array]$uninstallKeys = Get-ChildItem ` + -Path $paths ` + -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath } + +$foundUninstaller = $false +foreach ($key in $uninstallKeys) { + if ($key.DisplayName -like $softwareNameLike) { + $foundUninstaller = $true + $uninstallCommand = if ($key.QuietUninstallString) { + $key.QuietUninstallString + } else { + $key.UninstallString + } + + # The uninstall command may contain command and args, like: + # "C:\Program Files\Mozilla Firefox ESR\uninstall\helper.exe" /S + $splitArgs = $uninstallCommand.Split('"') + if ($splitArgs.Length -gt 1) { + if ($splitArgs.Length -eq 3) { + $existingArgs = $splitArgs[2].Trim() + if ($existingArgs -notmatch '\b/S\b') { + $uninstallArgs = "$existingArgs /S".Trim() + } else { + $uninstallArgs = $existingArgs + } + } elseif ($splitArgs.Length -gt 3) { + Throw ` + "Uninstall command contains multiple quoted strings. " + + "Please update the uninstall script.`n" + + "Uninstall command: $uninstallCommand" + } + $uninstallCommand = $splitArgs[1] + } else { + if ($uninstallCommand -notmatch '\b/S\b') { + $uninstallArgs = "/S" + } else { + $uninstallArgs = "" + } + } + Write-Host "Uninstall command: $uninstallCommand" + Write-Host "Uninstall args: $uninstallArgs" + + $processOptions = @{ + FilePath = $uninstallCommand + PassThru = $true + Wait = $true + } + + if ($uninstallArgs -ne '') { + $processOptions.ArgumentList = $uninstallArgs + } + + $process = Start-Process @processOptions + $exitCode = $process.ExitCode + Write-Host "Uninstall exit code: $exitCode" + break + } +} + +if (-not $foundUninstaller) { + Write-Host "Uninstaller for Firefox ESR not found." + $exitCode = 1 +} + +Exit $exitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/outputs/apps.json b/ee/maintained-apps/outputs/apps.json index 7f5c373161d..3ac7b62fb79 100644 --- a/ee/maintained-apps/outputs/apps.json +++ b/ee/maintained-apps/outputs/apps.json @@ -708,6 +708,20 @@ "unique_identifier": "Mozilla Firefox (x64 en-US)", "description": "Firefox is a powerful, open-source web browser built for speed, privacy, and customization." }, + { + "name": "Mozilla Firefox ESR", + "slug": "firefox@esr/darwin", + "platform": "darwin", + "unique_identifier": "org.mozilla.firefox", + "description": "Mozilla Firefox ESR is the Extended Support Release version of the popular web browser Firefox." + }, + { + "name": "Mozilla Firefox ESR", + "slug": "firefox@esr/windows", + "platform": "windows", + "unique_identifier": "Mozilla Firefox 140.7.1 ESR (x64 en-US)", + "description": "Mozilla Firefox ESR is the Extended Support Release version of the popular web browser Firefox." + }, { "name": "Fork", "slug": "fork/darwin", diff --git a/ee/maintained-apps/outputs/firefox@esr/darwin.json b/ee/maintained-apps/outputs/firefox@esr/darwin.json new file mode 100644 index 00000000000..323f1bcc0fb --- /dev/null +++ b/ee/maintained-apps/outputs/firefox@esr/darwin.json @@ -0,0 +1,21 @@ +{ + "versions": [ + { + "version": "140.7.1", + "queries": { + "exists": "SELECT 1 FROM apps WHERE bundle_identifier = 'org.mozilla.firefox';" + }, + "installer_url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/140.7.1esr/mac/en-US/Firefox%20140.7.1esr.dmg", + "install_script_ref": "a1338ebc", + "uninstall_script_ref": "64890c61", + "sha256": "7ba2fd895f5d3ffdfedf94ad9275b0390efe5800fc46ed82e46f205552157279", + "default_categories": [ + "Browsers" + ] + } + ], + "refs": { + "64890c61": "#!/bin/sh\n\n# variables\nAPPDIR=\"/Applications/\"\nLOGGED_IN_USER=$(scutil <<< \"show State:/Users/ConsoleUser\" | awk '/Name :/ { print $3 }')\n# functions\n\nquit_application() {\n local bundle_id=\"$1\"\n local timeout_duration=10\n\n # check if the application is running\n if ! osascript -e \"application id \\\"$bundle_id\\\" is running\" 2>/dev/null; then\n return\n fi\n\n local console_user\n console_user=$(stat -f \"%Su\" /dev/console)\n if [[ $EUID -eq 0 && \"$console_user\" == \"root\" ]]; then\n echo \"Not logged into a non-root GUI; skipping quitting application ID '$bundle_id'.\"\n return\n fi\n\n echo \"Quitting application '$bundle_id'...\"\n\n # try to quit the application within the timeout period\n local quit_success=false\n SECONDS=0\n while (( SECONDS < timeout_duration )); do\n if osascript -e \"tell application id \\\"$bundle_id\\\" to quit\" >/dev/null 2>&1; then\n if ! pgrep -f \"$bundle_id\" >/dev/null 2>&1; then\n echo \"Application '$bundle_id' quit successfully.\"\n quit_success=true\n break\n fi\n fi\n sleep 1\n done\n\n if [[ \"$quit_success\" = false ]]; then\n echo \"Application '$bundle_id' did not quit.\"\n fi\n}\n\n\ntrash() {\n local logged_in_user=\"$1\"\n local target_file=\"$2\"\n local timestamp=\"$(date +%Y-%m-%d-%s)\"\n local rand=\"$(jot -r 1 0 99999)\"\n\n # replace ~ with /Users/$logged_in_user\n if [[ \"$target_file\" == ~* ]]; then\n target_file=\"/Users/$logged_in_user${target_file:1}\"\n fi\n\n local trash=\"/Users/$logged_in_user/.Trash\"\n local file_name=\"$(basename \"${target_file}\")\"\n\n if [[ -e \"$target_file\" ]]; then\n echo \"removing $target_file.\"\n mv -f \"$target_file\" \"$trash/${file_name}_${timestamp}_${rand}\"\n else\n echo \"$target_file doesn't exist.\"\n fi\n}\n\nquit_application 'org.mozilla.firefox'\nsudo rm -rf '/Library/Logs/DiagnosticReports/firefox_*'\nsudo rm -rf \"$APPDIR/Firefox.app\"\nsudo rmdir '~/Library/Application Support/Mozilla'\nsudo rmdir '~/Library/Caches/Mozilla'\nsudo rmdir '~/Library/Caches/Mozilla/updates'\nsudo rmdir '~/Library/Caches/Mozilla/updates/Applications'\ntrash $LOGGED_IN_USER '~/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/org.mozilla.firefox.sfl*'\ntrash $LOGGED_IN_USER '~/Library/Application Support/CrashReporter/firefox_*'\ntrash $LOGGED_IN_USER '~/Library/Application Support/Firefox'\ntrash $LOGGED_IN_USER '~/Library/Caches/Firefox'\ntrash $LOGGED_IN_USER '~/Library/Caches/Mozilla/updates/Applications/Firefox'\ntrash $LOGGED_IN_USER '~/Library/Caches/org.mozilla.crashreporter'\ntrash $LOGGED_IN_USER '~/Library/Caches/org.mozilla.firefox'\ntrash $LOGGED_IN_USER '~/Library/Preferences/org.mozilla.crashreporter.plist'\ntrash $LOGGED_IN_USER '~/Library/Preferences/org.mozilla.firefox.plist'\ntrash $LOGGED_IN_USER '~/Library/Saved Application State/org.mozilla.firefox.savedState'\ntrash $LOGGED_IN_USER '~/Library/WebKit/org.mozilla.firefox'\n", + "a1338ebc": "#!/bin/sh\n\n# variables\nAPPDIR=\"/Applications/\"\nTMPDIR=$(dirname \"$(realpath $INSTALLER_PATH)\")\n# functions\n\nquit_and_track_application() {\n local bundle_id=\"$1\"\n local var_name=\"APP_WAS_RUNNING_$(echo \"$bundle_id\" | tr '.-' '__')\"\n local timeout_duration=10\n\n # check if the application is running\n if ! osascript -e \"application id \\\"$bundle_id\\\" is running\" 2>/dev/null; then\n eval \"export $var_name=0\"\n return\n fi\n\n local console_user\n console_user=$(stat -f \"%Su\" /dev/console)\n if [[ $EUID -eq 0 && \"$console_user\" == \"root\" ]]; then\n echo \"Not logged into a non-root GUI; skipping quitting application ID '$bundle_id'.\"\n eval \"export $var_name=0\"\n return\n fi\n\n # App was running, mark it for relaunch\n eval \"export $var_name=1\"\n echo \"Application '$bundle_id' was running; will relaunch after installation.\"\n\n echo \"Quitting application '$bundle_id'...\"\n\n # try to quit the application within the timeout period\n local quit_success=false\n SECONDS=0\n while (( SECONDS < timeout_duration )); do\n if osascript -e \"tell application id \\\"$bundle_id\\\" to quit\" >/dev/null 2>&1; then\n if ! pgrep -f \"$bundle_id\" >/dev/null 2>&1; then\n echo \"Application '$bundle_id' quit successfully.\"\n quit_success=true\n break\n fi\n fi\n sleep 1\n done\n\n if [[ \"$quit_success\" = false ]]; then\n echo \"Application '$bundle_id' did not quit.\"\n fi\n}\n\n\nrelaunch_application() {\n local bundle_id=\"$1\"\n local var_name=\"APP_WAS_RUNNING_$(echo \"$bundle_id\" | tr '.-' '__')\"\n local was_running\n\n # Check if the app was running before installation\n eval \"was_running=\\$$var_name\"\n if [[ \"$was_running\" != \"1\" ]]; then\n return\n fi\n\n local console_user\n console_user=$(stat -f \"%Su\" /dev/console)\n if [[ $EUID -eq 0 && \"$console_user\" == \"root\" ]]; then\n echo \"Not logged into a non-root GUI; skipping relaunching application ID '$bundle_id'.\"\n return\n fi\n\n echo \"Relaunching application '$bundle_id'...\"\n\n # Try to launch the application\n if osascript -e \"tell application id \\\"$bundle_id\\\" to activate\" >/dev/null 2>&1; then\n echo \"Application '$bundle_id' relaunched successfully.\"\n else\n echo \"Failed to relaunch application '$bundle_id'.\"\n fi\n}\n\n\n# extract contents\nMOUNT_POINT=$(mktemp -d /tmp/dmg_mount_XXXXXX)\nhdiutil attach -plist -nobrowse -readonly -mountpoint \"$MOUNT_POINT\" \"$INSTALLER_PATH\"\nsudo cp -R \"$MOUNT_POINT\"/* \"$TMPDIR\"\nhdiutil detach \"$MOUNT_POINT\"\n# copy to the applications folder\nquit_and_track_application 'org.mozilla.firefox'\nif [ -d \"$APPDIR/Firefox.app\" ]; then\n\tsudo mv \"$APPDIR/Firefox.app\" \"$TMPDIR/Firefox.app.bkp\"\nfi\nsudo cp -R \"$TMPDIR/Firefox.app\" \"$APPDIR\"\nrelaunch_application 'org.mozilla.firefox'\n" + } +} diff --git a/ee/maintained-apps/outputs/firefox@esr/windows.json b/ee/maintained-apps/outputs/firefox@esr/windows.json new file mode 100644 index 00000000000..908fa742620 --- /dev/null +++ b/ee/maintained-apps/outputs/firefox@esr/windows.json @@ -0,0 +1,21 @@ +{ + "versions": [ + { + "version": "140.7.1", + "queries": { + "exists": "SELECT 1 FROM programs WHERE name = 'Mozilla Firefox 140.7.1 ESR (x64 en-US)' AND publisher = 'Mozilla';" + }, + "installer_url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/140.7.1esr/win64/en-US/Firefox%20Setup%20140.7.1esr.exe", + "install_script_ref": "36995f4f", + "uninstall_script_ref": "5dc712f7", + "sha256": "f086d443a16053d97ae2a75f9a300afbcbe41b6dc213b99c57a76fdb5b692258", + "default_categories": [ + "Browsers" + ] + } + ], + "refs": { + "36995f4f": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n$installDir = \"C:\\Program Files\\Mozilla Firefox\"\n$maxWaitSeconds = 120\n\ntry {\n\n# Start silent install without -Wait; the Firefox ESR installer launches the\n# browser after installing and blocks until it is closed.\nStart-Process -FilePath \"$exeFilePath\" -ArgumentList \"/S\"\n\n# Poll for installation to complete\n$elapsed = 0\nwhile ($elapsed -lt $maxWaitSeconds) {\n Start-Sleep -Seconds 5\n $elapsed += 5\n if (Test-Path \"$installDir\\firefox.exe\") {\n Write-Host \"Firefox ESR installed successfully after $elapsed seconds\"\n Exit 0\n }\n}\n\nWrite-Host \"Timed out waiting for Firefox ESR to install\"\nExit 1\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "5dc712f7": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n# Match Firefox ESR only (e.g. \"Mozilla Firefox 140.7.1 ESR (x64 en-US)\"), not regular Firefox\n$softwareNameLike = \"*Firefox*ESR*\"\n\n# NSIS installers require /S flag for silent uninstall\n$uninstallArgs = \"/S\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path $paths `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Mozilla Firefox ESR\\uninstall\\helper.exe\" /S\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $existingArgs = $splitArgs[2].Trim()\n if ($existingArgs -notmatch '\\b/S\\b') {\n $uninstallArgs = \"$existingArgs /S\".Trim()\n } else {\n $uninstallArgs = $existingArgs\n }\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n } else {\n if ($uninstallCommand -notmatch '\\b/S\\b') {\n $uninstallArgs = \"/S\"\n } else {\n $uninstallArgs = \"\"\n }\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = $uninstallArgs\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for Firefox ESR not found.\"\n $exitCode = 1\n}\n\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + } +} diff --git a/frontend/pages/SoftwarePage/components/icons/Firefox.tsx b/frontend/pages/SoftwarePage/components/icons/Firefox.tsx index 6b0473dc862..8ff67dbb6fc 100644 --- a/frontend/pages/SoftwarePage/components/icons/Firefox.tsx +++ b/frontend/pages/SoftwarePage/components/icons/Firefox.tsx @@ -1,180 +1,14 @@ -import React from "react"; +import * as React from "react"; -import { uniqueId } from "lodash"; import type { SVGProps } from "react"; -const Firefox = (props: SVGProps) => { - // Create unique IDs for the SVG gradients - const gradientA = uniqueId("gradient-"); - const gradientB = uniqueId("gradient-"); - const gradientC = uniqueId("gradient-"); - const gradientD = uniqueId("gradient-"); - const gradientE = uniqueId("gradient-"); - const gradientF = uniqueId("gradient-"); - const gradientG = uniqueId("gradient-"); - const gradientH = uniqueId("gradient-"); - const gradientI = uniqueId("gradient-"); - const gradientJ = uniqueId("gradient-"); - const gradientK = uniqueId("gradient-"); - const gradientL = uniqueId("gradient-"); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; +const Firefox = (props: SVGProps) => ( + + + +); export default Firefox; diff --git a/website/assets/images/app-icon-firefox@esr-60x60@2x.png b/website/assets/images/app-icon-firefox@esr-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..05c08b1a474873c7dffe7b4982e3e1e1e77f4ade GIT binary patch literal 14213 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

R*gYFNF}tN{=}X&qVTqRBQ z#vXrn&yWoZT}+a#5nRIfiZEd@d@A8XX2;o?=aSTNo-Ky&ryEdR+cUWv9sxwHw2|Km zVE1hfErc2VwAblD(~X&6fy#tRRm?Stu;HH2;cW2Z`!IM_K>GC`Gosz^ZgUA;mto1c zhj+urAI-%4FXv11UKcFr5gwd~VCbdH&U+mF(IfmSozxxKMla(<)2k7+6?*?XO8P)C zlKQVhXVsTj_`4C>J^B?Hw0s-GXhD2Pa_*wl8|z?CnU{nd3`aA z+u%hAcuoY3SiO^P{)s-}4WEXU#{@ymVYzG&vh~^weYy#o+oq|so5J%&5ZKeI& zb}Br+yXp*?&1uojsD^0&ScKoxWtFGnv_#B6p+5Y{jzV{f~$icDX zo317*`83Y7IPyH;5`Z;ykM);SUV*3b)E|Apef(1^_tSMRdX97Ngr`-ke{ECCg2zkW z-@iM1MmCBwX$qUIY=yU?=c_d@^Lt~_Zams#Q(Ws}JGpf;S8__b=(X{J;GSjT3X=Rc zWP{MRY4gk(`A9raN!K8h9cc9q%9fk?knr9f7j0QR()(PgQ|`^E_2%|E=j!sa_p0nu zbHeW_I})U*uV8iFkzJ_n*O6-7754{sE)Q8fk#B$PLfV(kB>nCyLFm}|(Y7$&p)9r- zjEhy#3i0mNlHEtz8^&ugnC8QX&)55T1s7GQrL?{MRRRu;c>ny0q9Ucbl^*br!hze) zthv(l!Tzjs^XBzcL-NJd>&B#!xW9^}d6oBqAo+M=pQ8>_#^s;Q(O>FGJCB~k`e>4w z%lEXmn!b1eT|70PVTcu$R~x5GADGusBr8ya1&XCtyvoS7RY;O!&sf&>q5F=c z48WGE_@W-7`7$c#XZjl2%z?9J$GDHrA_pD0GmZ&(+PM+)U*URtgZg`@O`huG_UgI? zDV>zs5TylV9W;p0Sp@Yx_SOJo*3wgQwpKhAsV~sYuu+xrpUw&z2#U{itw%E%O#_Rz!^D&3}h|Z_`*iK0dCit^J02bWs_lJEldDT*a`j zJ(*&vp-UT_5R{D6DH1+*(k(IWJalcm*WkT`c4Ycdp@&u;`tTa`YU&>*Q&@X+pT4pA z5hoB6l@badz`uL|W|8b||Jw0{)3a^Uwty+QEUqz|NdMWXxxTu-zD`fpAW5c$kB^^@ z%_4t_TlBE%na!$ZH0`IM-sOidslfNb(__RlYk;^z!h4$)8rxtotPptTNR1D_&sK<~ zTbQNjqN6zuA)Qe>;z3*ZI`nVsltG4nfcuS|){OHibSFmgCq+C)Zhf)z8$%oGBuqW4 zTu#HUmw!;q#Q)0ncx)-1y=p~Pvab-y3LW)KrDPr7s}1M;zR$oc%>He5`T0gQcIx;m z$hq&S_JSxwtvzkEvcc1)^{K+{QR4%^>v>!Ufk5IvXzHEbRs<3OIe@UkhSt$sdF>dQ ztCBBzm~xnyC#N1(0tbaIuAQgc2Mhms^!ZG;l!hG@%lep?hh6%0up9D@yT>eLus6pWW2a(vYr~m)} literal 0 HcmV?d00001 From f1d9e9337102512cddb4da4d9110555cdb546f25 Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:01:59 -0500 Subject: [PATCH 037/141] Add Okta management hints to GitOps workflow (#39759) Add mobile management hint secrets in the dogfood GitOps workflow by adding DOGFOOD_OKTA_ANDROID_MANAGEMENT_HINT and DOGFOOD_OKTA_IOS_MANAGEMENT_HINT to the job environment. These values are sourced from repository secrets and are intended for Okta Android/iOS management hint configuration during the workflow run. No other behavior was changed. --- .github/workflows/dogfood-gitops.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/dogfood-gitops.yml b/.github/workflows/dogfood-gitops.yml index c8d10c7212d..a1402e0f145 100644 --- a/.github/workflows/dogfood-gitops.yml +++ b/.github/workflows/dogfood-gitops.yml @@ -82,6 +82,8 @@ jobs: DOGFOOD_END_USER_SSO_METADATA: ${{ secrets.DOGFOOD_END_USER_SSO_METADATA }} DOGFOOD_TESTING_AND_QA_ENROLL_SECRET: ${{ secrets.DOGFOOD_TESTING_AND_QA_ENROLL_SECRET }} DOGFOOD_OKTA_CA_CERTIFICATE: ${{ secrets.DOGFOOD_OKTA_CA_CERTIFICATE }} + DOGFOOD_OKTA_ANDROID_MANAGEMENT_HINT: ${{ secrets.DOGFOOD_OKTA_ANDROID_MANAGEMENT_HINT }} + DOGFOOD_OKTA_IOS_MANAGEMENT_HINT: ${{ secrets.DOGFOOD_OKTA_IOS_MANAGEMENT_HINT }} DOGFOOD_OKTA_VERIFY_WINDOWS_URL: ${{ secrets.DOGFOOD_OKTA_VERIFY_WINDOWS_URL }} DOGFOOD_ENTRA_TENANT_ID: ${{ secrets.DOGFOOD_ENTRA_TENANT_ID }} DOGFOOD_OKTA_METADATA_URL_ADMINS: ${{ secrets.DOGFOOD_OKTA_METADATA_URL_ADMINS }} From 6cc2836c207ba3247ec8c1b1258c1560ced978b3 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:06:45 -0500 Subject: [PATCH 038/141] Fixed BitLocker encryption failing after migrating. (#41911) **Related issue:** Resolves #33529 # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] QA'd all new/changed functionality manually ## fleetd/orbit/Fleet Desktop - [x] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [x] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [x] Verified that fleetd runs on macOS, Linux and Windows - [x] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) --- orbit/changes/33529-bitlocker-mdm-migration | 1 + orbit/pkg/bitlocker/bitlocker_management.go | 1 + .../bitlocker/bitlocker_management_windows.go | 105 +++++++++++++++++- orbit/pkg/update/notifications.go | 2 +- 4 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 orbit/changes/33529-bitlocker-mdm-migration diff --git a/orbit/changes/33529-bitlocker-mdm-migration b/orbit/changes/33529-bitlocker-mdm-migration new file mode 100644 index 00000000000..bb42e4563c8 --- /dev/null +++ b/orbit/changes/33529-bitlocker-mdm-migration @@ -0,0 +1 @@ +* Fixed BitLocker encryption failing with E_INVALIDARG after migrating Windows devices from another MDM. Fleet now reads the OSEncryptionType registry policy to use the correct encryption mode and cleans up stale key protectors from previous failed attempts. diff --git a/orbit/pkg/bitlocker/bitlocker_management.go b/orbit/pkg/bitlocker/bitlocker_management.go index f338cc44449..3e7fb8b682d 100644 --- a/orbit/pkg/bitlocker/bitlocker_management.go +++ b/orbit/pkg/bitlocker/bitlocker_management.go @@ -36,6 +36,7 @@ const ( const ( // Error Codes + ErrorCodeInvalidArg int32 = -2147024809 // E_INVALIDARG: encryption flags conflict with Group Policy ErrorCodeIODevice int32 = -2147023779 ErrorCodeDriveIncompatibleVolume int32 = -2144272206 ErrorCodeNoTPMWithPassphrase int32 = -2144272212 diff --git a/orbit/pkg/bitlocker/bitlocker_management_windows.go b/orbit/pkg/bitlocker/bitlocker_management_windows.go index 64fc504077a..7c331b07908 100644 --- a/orbit/pkg/bitlocker/bitlocker_management_windows.go +++ b/orbit/pkg/bitlocker/bitlocker_management_windows.go @@ -3,11 +3,14 @@ package bitlocker import ( + "errors" "fmt" "syscall" "github.com/go-ole/go-ole" "github.com/go-ole/go-ole/oleutil" + "github.com/rs/zerolog/log" + "golang.org/x/sys/windows/registry" ) // Encryption Methods @@ -30,7 +33,8 @@ const ( type EncryptionFlag int32 const ( - EncryptDataOnly EncryptionFlag = 0x00000001 + EncryptDataOnly EncryptionFlag = 0x00000001 + // EncryptDemandWipe encrypts the entire disk, including (wiping) free space. EncryptDemandWipe EncryptionFlag = 0x00000002 EncryptSynchronous EncryptionFlag = 0x00010000 ) @@ -65,6 +69,8 @@ func encryptErrHandler(val int32) error { var msg string switch val { + case ErrorCodeInvalidArg: + msg = "the encryption flags conflict with the current Group Policy settings (check HKLM\\SOFTWARE\\Policies\\Microsoft\\FVE)" case ErrorCodeIODevice: msg = "an I/O error has occurred during encryption; the device may need to be reset" case ErrorCodeDriveIncompatibleVolume: @@ -209,6 +215,18 @@ func (v *Volume) protectWithTPM(platformValidationProfile *[]uint8) error { return nil } +// deleteKeyProtectors removes all key protectors from the volume. +// https://learn.microsoft.com/en-us/windows/win32/secprov/deletekeyprotectors-win32-encryptablevolume +func (v *Volume) deleteKeyProtectors() error { + resultRaw, err := oleutil.CallMethod(v.handle, "DeleteKeyProtectors") + if err != nil { + return fmt.Errorf("deleteKeyProtectors(%s): %w", v.letter, err) + } else if val, ok := resultRaw.Value().(int32); val != 0 || !ok { + return fmt.Errorf("deleteKeyProtectors(%s): %w", v.letter, encryptErrHandler(val)) + } + return nil +} + // getBitlockerStatus returns the current status of the volume // https://learn.microsoft.com/en-us/windows/win32/secprov/getprotectionstatus-win32-encryptablevolume func (v *Volume) getBitlockerStatus() (*EncryptionStatus, error) { @@ -355,6 +373,53 @@ func getBitlockerStatus(targetVolume string) (*EncryptionStatus, error) { // Bitlocker Management interface implementation ///////////////////////////////////////////////////// +// encryptionFlagFromRegistry reads the OSEncryptionType Group Policy registry value to determine +// the encryption flag. Other MDM solutions may set this key to require full disk +// encryption, and the key persists after unenrolling. If the policy requires full disk encryption, +// we honor it; otherwise we default to used-space-only. +// +// Registry values for OSEncryptionType (under HKLM\SOFTWARE\Policies\Microsoft\FVE): +// - 0: allow user to choose (default to used-space-only) +// - 1: full disk encryption required +// - 2: used-space-only encryption +// +// See https://learn.microsoft.com/en-us/windows/security/operating-system-security/data-protection/bitlocker/configure +func encryptionFlagFromRegistry() EncryptionFlag { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Policies\Microsoft\FVE`, registry.QUERY_VALUE) + if err != nil { + return EncryptDataOnly + } + defer k.Close() + + val, _, err := k.GetIntegerValue("OSEncryptionType") + if err != nil { + return EncryptDataOnly + } + + if val == 1 { + log.Info().Msg("OSEncryptionType registry policy requires full disk encryption, using full encryption mode") + return EncryptDemandWipe + } + + return EncryptDataOnly +} + +// deleteOSEncryptionTypeRegistry removes the OSEncryptionType value from the FVE policy registry +// key. This cleans up orphaned policy keys left by other MDM solutions after unenrolling. +func deleteOSEncryptionTypeRegistry() { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Policies\Microsoft\FVE`, registry.SET_VALUE) + if err != nil { + return + } + defer k.Close() + + if err := k.DeleteValue("OSEncryptionType"); err != nil { + log.Debug().Err(err).Msg("could not delete OSEncryptionType registry value") + } else { + log.Info().Msg("deleted orphaned OSEncryptionType registry policy value") + } +} + func encryptVolumeOnCOMThread(targetVolume string) (string, error) { // Connect to the volume vol, err := bitlockerConnect(targetVolume) @@ -363,9 +428,41 @@ func encryptVolumeOnCOMThread(targetVolume string) (string, error) { } defer vol.bitlockerClose() + // Clean up stale key protectors (recovery passwords, TPM, etc.) that may be left over from + // a previous failed encryption attempt or from another MDM solution. Without this, leftover + // protectors cause prepareVolume to return ErrorCodeNotDecrypted and subsequent encryption + // attempts to silently fail. Failures are logged but not fatal since a fresh volume won't + // have any protectors to delete. + if err := vol.deleteKeyProtectors(); err != nil { + log.Debug().Err(err).Msg("could not delete existing key protectors (may not have any), continuing anyway") + } + + // Read the OSEncryptionType registry policy to determine the encryption flag. If a GPO or + // another MDM set this to require full disk encryption (value 1), passing the wrong flag + // to Encrypt() would fail with E_INVALIDARG. We honor the policy to avoid this conflict. + // If the key is absent or any other value, we default to used-space-only (EncryptDataOnly). + encFlag := encryptionFlagFromRegistry() + + // Delete the registry key now that we've read it. If it was orphaned from a previous MDM, + // this cleans it up permanently. In practice, customers should not use GPO for BitLocker + // policy alongside Fleet MDM since the two will conflict. However, if an active GPO is present, + // it will re-apply the key on its next refresh (~90 minutes). Orbit retries every ~30 + // seconds on failure, so interim retries before the GPO re-applies the key will default to + // EncryptDataOnly and fail with E_INVALIDARG. That's expected and the error is reported to + // Fleet. Once the GPO restores the key, the next retry reads it and succeeds. + deleteOSEncryptionTypeRegistry() + // Prepare for encryption if err := vol.prepareVolume(VolumeTypeDefault, EncryptionTypeSoftware); err != nil { - return "", fmt.Errorf("preparing volume for encryption: %w", err) + // A previous failed encryption attempt may have already called PrepareVolume, which sets + // BitLocker metadata on the volume. Calling it again returns ErrorCodeNotDecrypted + // (FVE_E_NOT_DECRYPTED). This is safe to ignore because the volume is already prepared + // and we can proceed with adding protectors and encrypting. + var encErr *EncryptionError + if !errors.As(err, &encErr) || encErr.Code() != ErrorCodeNotDecrypted { + return "", fmt.Errorf("preparing volume for encryption: %w", err) + } + log.Debug().Msg("volume already prepared from previous attempt, continuing") } // Add a recovery protector @@ -379,8 +476,8 @@ func encryptVolumeOnCOMThread(targetVolume string) (string, error) { return "", fmt.Errorf("protecting with TPM: %w", err) } - // Start encryption - if err := vol.encrypt(XtsAES256, EncryptDataOnly); err != nil { + // Start encryption using the flag determined from the registry policy + if err := vol.encrypt(XtsAES256, encFlag); err != nil { return "", fmt.Errorf("starting encryption: %w", err) } diff --git a/orbit/pkg/update/notifications.go b/orbit/pkg/update/notifications.go index 0f7f7451f43..7e79dc5d013 100644 --- a/orbit/pkg/update/notifications.go +++ b/orbit/pkg/update/notifications.go @@ -551,7 +551,7 @@ func (w *windowsMDMBitlockerConfigReceiver) attemptBitlockerEncryption(notifs fl } if encryptionErr != nil { - log.Error().Err(err).Msg("failed to encrypt the volume") + log.Error().Err(encryptionErr).Msg("failed to encrypt the volume") return } From 8dfdb94885b3048d4c72582831bc828ea01a3521 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:14:14 -0500 Subject: [PATCH 039/141] Updated ingestion/CVE logic to support Jetbrains software with 2 version numbers (#42003) **Related issue:** Resolves #37323 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **Bug Fixes** * Improved JetBrains software version detection to support the newer two-part version format (e.g., WebStorm 2025.1). * Enhanced CVE/vulnerability tracking accuracy for JetBrains products with updated version number parsing. --- changes/37323-jetbrains-cve | 1 + server/service/osquery_utils/queries.go | 6 +++--- server/service/osquery_utils/queries_test.go | 10 ++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 changes/37323-jetbrains-cve diff --git a/changes/37323-jetbrains-cve b/changes/37323-jetbrains-cve new file mode 100644 index 00000000000..d2449e10b9e --- /dev/null +++ b/changes/37323-jetbrains-cve @@ -0,0 +1 @@ +* Updated ingestion/CVE logic to support JetBrains software with 2 version numbers, like WebStorm 2025.1 diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go index 5a55d29f9e0..e02bf62b80b 100644 --- a/server/service/osquery_utils/queries.go +++ b/server/service/osquery_utils/queries.go @@ -2158,9 +2158,9 @@ func directIngestSoftware(ctx context.Context, logger *slog.Logger, host *fleet. var ( dcvVersionFormat = regexp.MustCompile(`^(\d+\.\d+)\s*\(r(\d+)\)$`) tunnelblickVersionFormat = regexp.MustCompile(`^(.+?)\s*\(build\s+\d+\)$`) - // jetbrainsNameVersion extracts version from JetBrains product names like "GoLand 2025.3.3" - // or "IntelliJ IDEA 2025.3.1.1" (supports 3 or 4 part versions) - jetbrainsNameVersion = regexp.MustCompile(`\s(\d{4}\.\d+\.\d+(?:\.\d+)?)$`) + // jetbrainsNameVersion extracts version from JetBrains product names like "WebStorm 2025.1", + // "GoLand 2025.3.3", or "IntelliJ IDEA 2025.3.1.1" (supports 2, 3, or 4 part versions) + jetbrainsNameVersion = regexp.MustCompile(`\s(\d{4}\.\d+(?:\.\d+){0,2})$`) basicAppSanitizers = []struct { matchBundleIdentifier string matchName string diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go index 83cd38226d6..1f03e5f3f06 100644 --- a/server/service/osquery_utils/queries_test.go +++ b/server/service/osquery_utils/queries_test.go @@ -143,6 +143,16 @@ func TestSoftwareIngestionMutations(t *testing.T) { MutateSoftwareOnIngestion(t.Context(), jetbrainsGoLand, slog.New(slog.DiscardHandler)) assert.Equal(t, "2025.3.3", jetbrainsGoLand.Version) + // Test JetBrains with 2-part year.minor version (like "WebStorm 2025.1") + jetbrainsWebStorm := &fleet.Software{ + Name: "WebStorm 2025.1", + Source: "programs", + Vendor: "JetBrains s.r.o.", + Version: "251.23774.424", + } + MutateSoftwareOnIngestion(t.Context(), jetbrainsWebStorm, slog.New(slog.DiscardHandler)) + assert.Equal(t, "2025.1", jetbrainsWebStorm.Version) + // Test JetBrains with 4-part version number jetbrainsIntelliJ := &fleet.Software{ Name: "IntelliJ IDEA 2025.3.1.1", From db5fb9b2308bda626cde42a71245ebede92f8cd9 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:19:42 -0500 Subject: [PATCH 040/141] Update golangci-lint from 2.7.1 to 2.11.3 (#42066) --- .custom-gcl.yml | 2 +- .github/workflows/golangci-lint.yml | 4 ++-- .golangci-incremental.yml | 16 ++++++++++++++++ .golangci.yml | 17 ++++++++++++++++- .pre-commit-config.yaml | 2 +- .../testing-and-local-development.md | 2 +- server/service/mdm.go | 2 +- server/service/software_installers.go | 2 +- server/service/vpp.go | 4 ++-- 9 files changed, 41 insertions(+), 10 deletions(-) diff --git a/.custom-gcl.yml b/.custom-gcl.yml index 3b85dff0eb4..d6676782c2c 100644 --- a/.custom-gcl.yml +++ b/.custom-gcl.yml @@ -1,7 +1,7 @@ # This configures how golangci-lint builds a custom build, wich is necessary to use nilaway as a plugin per https://github.com/uber-go/nilaway?tab=readme-ov-file#golangci-lint--v1570 # This has to be >= v1.57.0 for module plugin system support. -version: v2.7.1 +version: v2.11.3 plugins: - module: "go.uber.org/nilaway" import: "go.uber.org/nilaway/cmd/gclplugin" diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index f3e6b2d3b17..0ccc64abef1 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -73,7 +73,7 @@ jobs: run: | # Don't forget to update # docs/Contributing/Testing-and-local-development.md when this version changes - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@a4b55ebc3471c9fbb763fd56eefede8050f99887 # v2.7.1 + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@6008b81b81c690c046ffc3fd5bce896da715d5fd # v2.11.3 SKIP_INCREMENTAL=1 make lint-go - name: Run cloner-check tool @@ -136,7 +136,7 @@ jobs: run: | # Don't forget to update # docs/Contributing/Testing-and-local-development.md when this version changes - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@a4b55ebc3471c9fbb763fd56eefede8050f99887 # v2.7.1 + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@6008b81b81c690c046ffc3fd5bce896da715d5fd # v2.11.3 # custom build of golangci-lint that incorporates nilaway - see .custom-gcl.yml golangci-lint custom ./custom-gcl run -c .golangci-incremental.yml --new-from-rev=origin/${{ github.base_ref }} --timeout 15m ./... diff --git a/.golangci-incremental.yml b/.golangci-incremental.yml index be027cc707b..d7a9ed92676 100644 --- a/.golangci-incremental.yml +++ b/.golangci-incremental.yml @@ -10,9 +10,25 @@ issues: linters: default: none enable: + - gosec - modernize - nilaway settings: + gosec: + # Only enable rules that are too noisy on existing code but valuable for new code. + # Existing violations were audited during the v2.7.1 -> v2.11.3 upgrade and found + # to be false positives or safe patterns, but we want to catch real issues going forward. + includes: + - G101 # Potential hardcoded credentials. + - G115 # Integer overflow conversion. + - G117 # Marshaled struct field matches secret pattern. + - G118 # Goroutine uses context.Background/TODO while request-scoped context is available. + - G122 # Filesystem race in filepath.Walk/WalkDir callback. + - G202 # SQL string concatenation. + - G602 # Slice index out of range. + - G704 # SSRF via taint analysis. + - G705 # XSS via taint analysis. + - G706 # Log injection via taint analysis. custom: nilaway: type: module diff --git a/.golangci.yml b/.golangci.yml index 7023fba2a0c..3681ff5bd00 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -177,7 +177,22 @@ linters: - G104 # Errors unhandled. We are using errcheck linter instead of this rule. - G204 # Subprocess launched with variable. Some consider this rule to be too noisy. - G301 # Directory permissions 0750 as opposed to standard 0755. Consider enabling stricter permission in the future. - - G304 # File path provided as taint input + - G304 # File path provided as taint input. + - G702 # Command injection via taint analysis (taint version of excluded G204). + - G703 # Path traversal via taint analysis (taint version of excluded G304). + # The following rules are excluded from the full lint but enabled in the incremental + # linter (.golangci-incremental.yml) so they only apply to new/changed code. + # Existing violations were audited during the v2.7.1 -> v2.11.3 upgrade. + - G101 # Potential hardcoded credentials. + - G115 # Integer overflow conversion. + - G117 # Marshaled struct field matches secret pattern. + - G118 # Goroutine uses context.Background/TODO while request-scoped context is available. + - G122 # Filesystem race in filepath.Walk/WalkDir callback. + - G202 # SQL string concatenation. + - G602 # Slice index out of range. + - G704 # SSRF via taint analysis. + - G705 # XSS via taint analysis. + - G706 # Log injection via taint analysis. config: G306: "0644" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0726a2f7f0..b67760c527b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: hooks: - id: gitleaks - repo: https://github.com/golangci/golangci-lint - rev: v2.7.1 + rev: v2.11.3 hooks: - id: golangci-lint - repo: https://github.com/jumanjihouse/pre-commit-hooks diff --git a/docs/Contributing/getting-started/testing-and-local-development.md b/docs/Contributing/getting-started/testing-and-local-development.md index 512831e948c..debe254aed9 100644 --- a/docs/Contributing/getting-started/testing-and-local-development.md +++ b/docs/Contributing/getting-started/testing-and-local-development.md @@ -73,7 +73,7 @@ Check out [`/tools/osquery` directory instructions](https://github.com/fleetdm/f You must install the [`golangci-lint`](https://golangci-lint.run/) command to run `make test[-go]` or `make lint[-go]`, using: ```sh -go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@a4b55ebc3471c9fbb763fd56eefede8050f99887 +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@6008b81b81c690c046ffc3fd5bce896da715d5fd ``` This installs the version of `golangci-lint` used in our CI environment (currently 2.7.1). Make sure it is available in your `PATH`. To execute the basic unit and integration tests, run the following from the root of the repository: diff --git a/server/service/mdm.go b/server/service/mdm.go index fa8e3694c7c..534dbff0c4b 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -3171,7 +3171,7 @@ func (uploadMDMAppleAPNSCertRequest) DecodeRequest(ctx context.Context, r *http. } } - if r.MultipartForm.File["certificate"] == nil || len(r.MultipartForm.File["certificate"]) == 0 { + if len(r.MultipartForm.File["certificate"]) == 0 { return nil, &fleet.BadRequestError{ Message: "certificate multipart field is required", InternalErr: err, diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 8feb6fc7f05..94fc118cef0 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -299,7 +299,7 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http } } - if r.MultipartForm.File["software"] == nil || len(r.MultipartForm.File["software"]) == 0 { + if len(r.MultipartForm.File["software"]) == 0 { return nil, &fleet.BadRequestError{ Message: "software multipart field is required", InternalErr: err, diff --git a/server/service/vpp.go b/server/service/vpp.go index c79cd42120d..e7ba558c3a7 100644 --- a/server/service/vpp.go +++ b/server/service/vpp.go @@ -205,7 +205,7 @@ func (uploadVPPTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) } } - if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { + if len(r.MultipartForm.File["token"]) == 0 { return nil, &fleet.BadRequestError{ Message: "token multipart field is required", InternalErr: err, @@ -272,7 +272,7 @@ func (patchVPPTokenRenewRequest) DecodeRequest(ctx context.Context, r *http.Requ } } - if r.MultipartForm.File["token"] == nil || len(r.MultipartForm.File["token"]) == 0 { + if len(r.MultipartForm.File["token"]) == 0 { return nil, &fleet.BadRequestError{ Message: "token multipart field is required", InternalErr: err, From 12f8ae4f3fe2fadfd03727237ace752f37311c46 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 19 Mar 2026 11:30:02 -0500 Subject: [PATCH 041/141] Website: Update card titles on customers page (#42083) Changes: - Updated duplicated case study card titles that I missed in https://github.com/fleetdm/fleet/pull/42072 --- website/views/pages/testimonials.ejs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/views/pages/testimonials.ejs b/website/views/pages/testimonials.ejs index cc61c5121e4..9f458c338dd 100644 --- a/website/views/pages/testimonials.ejs +++ b/website/views/pages/testimonials.ejs @@ -189,12 +189,12 @@ Read their story

-

Communications platform

+

Cybersecurity company

A cybersecurity company improves device visibility.

Read their story
-

Communications platform

+

Cybersecurity company

Cybersecurity company improves Linux management with Fleet, replacing legacy tools.

Read their story
@@ -244,12 +244,12 @@ Read their story
-

Gaming platform gains production visibility

+

Financial technology company

A fintech company manages a global remote workforce with Fleet.

Read their story
-

Gaming platform gains production visibility

+

Financial technology company

Fleet gives a fintech company real-time infrastructure visibility across laptops and cloud systems.

Read their story
@@ -304,7 +304,7 @@ Read their story
-

Online gaming platform

+

Identity security company

Identity security company unifies macOS and Windows device management.

Read their story
From 705856e7eb695884627d3c7d9056750abb5607db Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Thu, 19 Mar 2026 10:42:14 -0600 Subject: [PATCH 042/141] Recovery lock tooltip copy update (#41978) --- .../HostActionsDropdown/HostActionsDropdown.tests.tsx | 2 +- .../HostDetailsPage/HostActionsDropdown/helpers.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx index 19762c250ab..85f331c8696 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx @@ -1730,7 +1730,7 @@ describe("Host Actions Dropdown", () => { await user.hover(option); await waitFor(() => { expect( - screen.getByText(/Recovery Lock password is not available/i) + screen.getByText(/Recovery Lock password is unavailable/i) ).toBeInTheDocument(); }); }); diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx index 94c51573c90..777e4d8470f 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx @@ -542,7 +542,13 @@ const modifyOptions = ( ); if (rlpOption) { rlpOption.disabled = true; - rlpOption.tooltipContent = <>Recovery Lock password is not available.; + rlpOption.tooltipContent = ( + <> + Recovery Lock password is unavailable +
+ while pending or has failed. + + ); } } From e8ea01dedff1e0d9dd2baa49fcf50f004c8cba9b Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Thu, 19 Mar 2026 10:45:50 -0600 Subject: [PATCH 043/141] View recovery password: fix permissions (#41951) --- server/service/hosts.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/service/hosts.go b/server/service/hosts.go index 7370e74752c..358d62e0dbc 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -3863,8 +3863,9 @@ func (svc *Service) GetHostRecoveryLockPassword(ctx context.Context, hostID uint return nil, ctxerr.Wrap(ctx, err, "get host") } - // Require admin or maintainer role (ActionWrite) for this sensitive data - if err := svc.authz.Authorize(ctx, host, fleet.ActionWrite); err != nil { + // Permissions to read recovery lock passwords are exactly the same + // as the ones required to read hosts. + if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil { return nil, err } From 0a7a01c3c92de6ad353355aa3dcd23e2c2aa8cf8 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Thu, 19 Mar 2026 13:00:38 -0400 Subject: [PATCH 044/141] lint --- server/service/integration_enterprise_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 9ce4025c149..6cc07f48771 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12426,7 +12426,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // Create a host and assign the label to it host := createOrbitEnrolledHost(t, "linux", "label_host", s.ds) - err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lblA.ID: ptr.Bool(true)}, time.Now(), false) + err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lblA.ID: new(true)}, time.Now(), false) require.NoError(t, err) // Attempt to install. Should fail because label is "exclude any" @@ -12528,9 +12528,9 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // patch the installer to update an unrelated field; labels_include_all should be preserved s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ - SelfService: ptr.Bool(true), - InstallScript: ptr.String("some install script"), - PreInstallQuery: ptr.String("some pre install query"), + SelfService: new(true), + InstallScript: new("some install script"), + PreInstallQuery: new("some pre install query"), Filename: "ruby.deb", TitleID: titleID, TeamID: nil, @@ -12544,7 +12544,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // create a host and assign the label β€” install should succeed since host has all required labels host := createOrbitEnrolledHost(t, "linux", "include_all_label_host", s.ds) - err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{labelResp.Label.ID: ptr.Bool(true)}, time.Now(), false) + err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{labelResp.Label.ID: new(true)}, time.Now(), false) require.NoError(t, err) s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/%d/install", host.ID, titleID), nil, http.StatusAccepted) @@ -18649,7 +18649,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( host, map[uint]*bool{ - policy3.ID: ptr.Bool(false), + policy3.ID: new(false), }, ), http.StatusOK, &distributedResp) err = s.ds.UpdateHostPolicyCounts(ctx) @@ -18717,7 +18717,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( host, map[uint]*bool{ - policy4.ID: ptr.Bool(false), + policy4.ID: new(false), }, ), http.StatusOK, &distributedResp) err = s.ds.UpdateHostPolicyCounts(ctx) @@ -18741,7 +18741,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers s.DoJSONWithoutAuth("POST", "/api/osquery/distributed/write", genDistributedReqWithPolicyResults( host, map[uint]*bool{ - policy4.ID: ptr.Bool(false), + policy4.ID: new(false), }, ), http.StatusOK, &distributedResp) err = s.ds.UpdateHostPolicyCounts(ctx) From 881f6a8f941d453b480503303cb705baeeed624f Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Thu, 19 Mar 2026 13:21:29 -0400 Subject: [PATCH 045/141] lint --- server/service/integration_enterprise_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 6cc07f48771..4886622b7c8 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -18734,7 +18734,7 @@ func (s *integrationEnterpriseTestSuite) TestPolicyAutomationsSoftwareInstallers require.Nil(t, host1LastInstall) // Now add lbl3 to the host and re-run the policy failure. vim should now be in scope. - err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl3.ID: ptr.Bool(true)}, time.Now(), false) + err = s.ds.RecordLabelQueryExecutions(context.Background(), host, map[uint]*bool{lbl3.ID: new(true)}, time.Now(), false) require.NoError(t, err) distributedResp = submitDistributedQueryResultsResponse{} From 2a0d0c380435f38f9cac39ab9a88e883b7e78ea3 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Thu, 19 Mar 2026 11:48:35 -0600 Subject: [PATCH 046/141] Recovery password integration tests (#41988) --- server/service/integration_mdm_test.go | 617 +++++++++++++++++++++++++ 1 file changed, 617 insertions(+) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 66428ab207b..c10df8efba0 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -22287,3 +22287,620 @@ func (s *integrationMDMTestSuite) TestTechnicianPermissions() { HostIDs: []uint{team1MacOSHost.ID}, }, http.StatusForbidden, &batchRes) } + +// TestRecoveryLockPasswordIntegration is a comprehensive test for the recovery lock password feature. +// It tests various scenarios including: +// - MDM on, feature on/off transitions +// - Cron job sending commands and host state transitions +// - MDM command acknowledgment (verified) and error (failed) handling +// - Host details API returning correct recovery lock password information +// - Activities being reported correctly +// - Rotate password API (premium feature) +// - Feature toggled off with hosts in different states +func (s *integrationMDMTestSuite) TestRecoveryLockPasswordIntegration() { + t := s.T() + + // Create a helper to create an Apple Silicon macOS host (required for recovery lock) + createAppleSiliconHost := func(t *testing.T) (*fleet.Host, *mdmtest.TestAppleMDMClient) { + t.Helper() + host, mdmClient := createHostThenEnrollMDM(s.ds, s.server.URL, t) + // Set the CPU type to ARM (Apple Silicon) - required for recovery lock + host.CPUType = "arm64" + err := s.ds.UpdateHost(t.Context(), host) + require.NoError(t, err) + return host, mdmClient + } + + // Helper to run the recovery lock cron job + runRecoveryLockCron := func(t *testing.T) { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + err := apple_mdm.SendRecoveryLockCommands(t.Context(), s.ds, s.mdmCommander, logger) + require.NoError(t, err) + } + + // Helper to get host details and return recovery lock password status + getHostRecoveryLockStatus := func(hostID uint) *fleet.HostMDMRecoveryLockPassword { + var getHostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostID), nil, http.StatusOK, &getHostResp) + return &getHostResp.Host.MDM.OSSettings.RecoveryLockPassword + } + + // ========================================================================= + // Test 1: MDM on, feature off - hosts should not get recovery lock password + // ========================================================================= + t.Run("MDM on, feature off - no recovery lock password set", func(t *testing.T) { + // Ensure recovery lock password is disabled (default) + acResp := appConfigResponse{} + s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) + require.False(t, acResp.MDM.EnableRecoveryLockPassword.Value) + + host, _ := createAppleSiliconHost(t) + + // Run cron - should not send any commands + runRecoveryLockCron(t) + + // Verify host has no recovery lock password status + rlpStatus := getHostRecoveryLockStatus(host.ID) + assert.Nil(t, rlpStatus.Status, "status should be nil when feature is disabled") + assert.False(t, rlpStatus.PasswordAvailable, "password should not be available when feature is disabled") + }) + + // ========================================================================= + // Test 2: MDM on, feature enabled - full lifecycle + // ========================================================================= + t.Run("MDM on, feature enabled - full lifecycle", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + // Verify activity was created for enabling recovery lock + s.lastActivityMatches(fleet.ActivityTypeEnabledRecoveryLockPasswords{}.ActivityName(), + `{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`, 0) + + host, mdmClient := createAppleSiliconHost(t) + + // Initial state: no recovery lock password + rlpStatus := getHostRecoveryLockStatus(host.ID) + assert.Nil(t, rlpStatus.Status) + + // Run cron - should send SetRecoveryLock command + runRecoveryLockCron(t) + + // Host should now be in pending state + rlpStatus = getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status, "status should be set after cron runs") + assert.Equal(t, fleet.RecoveryLockStatusPending, *rlpStatus.Status, "status should be pending") + + // Simulate MDM client checking in and receiving the command + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + assert.Equal(t, "SetRecoveryLock", cmd.Command.RequestType) + + // Acknowledge the command (success) + _, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // Host should now be in verified state + rlpStatus = getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusVerified, *rlpStatus.Status, "status should be verified after acknowledgment") + assert.True(t, rlpStatus.PasswordAvailable) + + // Verify activity was created for setting password + s.lastActivityOfTypeMatches(fleet.ActivityTypeSetHostRecoveryLockPassword{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0) + + // Disable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + }) + + // ========================================================================= + // Test 3: MDM command failure - host marked as failed + // ========================================================================= + t.Run("MDM command failure - host marked as failed", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + host, mdmClient := createAppleSiliconHost(t) + + // Run cron - should send SetRecoveryLock command + runRecoveryLockCron(t) + + // Host should be in pending state + rlpStatus := getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusPending, *rlpStatus.Status) + + // Simulate MDM client receiving command + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + assert.Equal(t, "SetRecoveryLock", cmd.Command.RequestType) + + // Simulate error response + _, err = mdmClient.Err(cmd.CommandUUID, []mdm.ErrorChain{ + {ErrorCode: 12066, ErrorDomain: "MCMDMErrorDomain", LocalizedDescription: "Recovery lock password could not be set"}, + }) + require.NoError(t, err) + + // Host should now be in failed state + rlpStatus = getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusFailed, *rlpStatus.Status, "status should be failed after error") + assert.Contains(t, rlpStatus.Detail, "Recovery lock password could not be set") + + // Disable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + }) + + // ========================================================================= + // Test 4: Get recovery lock password API + // ========================================================================= + t.Run("Get recovery lock password API", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + host, mdmClient := createAppleSiliconHost(t) + + // Run cron and acknowledge command + runRecoveryLockCron(t) + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + _, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // Get recovery lock password via API + var getPasswordResp getHostRecoveryLockPasswordResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp) + require.NotNil(t, getPasswordResp.RecoveryLockPassword) + assert.NotEmpty(t, getPasswordResp.RecoveryLockPassword.Password) + assert.NotZero(t, getPasswordResp.RecoveryLockPassword.UpdatedAt) + + // Verify activity was created for viewing password + s.lastActivityOfTypeMatches(fleet.ActivityTypeViewedHostRecoveryLockPassword{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0) + + // Disable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + }) + + // ========================================================================= + // Test 5: Rotate password API (enterprise feature - initiates rotation) + // ========================================================================= + t.Run("Rotate password API initiates rotation", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + host, mdmClient := createAppleSiliconHost(t) + + // Run cron and acknowledge command to get to verified state + runRecoveryLockCron(t) + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + _, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // Get the current password + var getPasswordResp getHostRecoveryLockPasswordResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp) + require.NotNil(t, getPasswordResp.RecoveryLockPassword) + originalPassword := getPasswordResp.RecoveryLockPassword.Password + + // Initiate rotation + var rotateResp rotateRecoveryLockPasswordResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password/rotate", host.ID), nil, http.StatusOK, &rotateResp) + + // Host should still have a password (pending rotation) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp) + require.NotNil(t, getPasswordResp.RecoveryLockPassword) + // Password should still be the original until rotation is acknowledged + assert.Equal(t, originalPassword, getPasswordResp.RecoveryLockPassword.Password) + + // MDM client receives SetRecoveryLock command for rotation + cmd, err = mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + assert.Equal(t, "SetRecoveryLock", cmd.Command.RequestType) + + // Acknowledge the rotation command + _, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // Password should now be different (rotated) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusOK, &getPasswordResp) + require.NotNil(t, getPasswordResp.RecoveryLockPassword) + assert.NotEqual(t, originalPassword, getPasswordResp.RecoveryLockPassword.Password, "password should be different after rotation") + + // Verify activity was created + s.lastActivityOfTypeMatches(fleet.ActivityTypeRotatedHostRecoveryLockPassword{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0) + + // Disable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + }) + + // ========================================================================= + // Test 6: Feature disabled with host in pending state + // ========================================================================= + t.Run("Feature disabled with host in pending state", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + host, mdmClient := createAppleSiliconHost(t) + + // Run cron - host goes to pending state + runRecoveryLockCron(t) + + rlpStatus := getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusPending, *rlpStatus.Status) + + // Disable the feature while host is in pending state + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + + // Run cron again - hosts in pending state are NOT claimed for clear + // (they haven't received the SetRecoveryLock command yet) + runRecoveryLockCron(t) + + // Host should still be in pending state + rlpStatus = getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusPending, *rlpStatus.Status) + + // Now simulate the MDM client receiving and acknowledging the SetRecoveryLock command + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + assert.Equal(t, "SetRecoveryLock", cmd.Command.RequestType) + _, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // Host is now verified + rlpStatus = getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusVerified, *rlpStatus.Status) + + // Run cron again - now the verified host should be claimed for clear + runRecoveryLockCron(t) + + // Host should be in removing_enforcement state + rlpStatus = getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusRemovingEnforcement, *rlpStatus.Status) + }) + + // ========================================================================= + // Test 7: Feature disabled with host in verified state + // ========================================================================= + t.Run("Feature disabled with host in verified state", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + host, mdmClient := createAppleSiliconHost(t) + + // Run cron and acknowledge to get to verified state + runRecoveryLockCron(t) + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + _, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + rlpStatus := getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusVerified, *rlpStatus.Status) + + // Disable the feature + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + + // Run cron - should claim host for clear + runRecoveryLockCron(t) + + // Host should be in removing_enforcement state + rlpStatus = getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusRemovingEnforcement, *rlpStatus.Status) + + // Simulate MDM client receiving ClearRecoveryLock command + cmd, err = mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + assert.Equal(t, "SetRecoveryLock", cmd.Command.RequestType) // ClearRecoveryLock is also SetRecoveryLock command type + + // Acknowledge clear command + _, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // Host should no longer have a recovery lock password record + rlpStatus = getHostRecoveryLockStatus(host.ID) + assert.Nil(t, rlpStatus.Status) + assert.False(t, rlpStatus.PasswordAvailable) + }) + + // ========================================================================= + // Test 8: Feature re-enabled with host in removing_enforcement state + // ========================================================================= + t.Run("Feature re-enabled with host in removing_enforcement state", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + host, mdmClient := createAppleSiliconHost(t) + + // Run cron and acknowledge to get to verified state + runRecoveryLockCron(t) + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + _, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // Disable feature to trigger clear + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + runRecoveryLockCron(t) + + // Verify host is in removing_enforcement state + rlpStatus := getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusRemovingEnforcement, *rlpStatus.Status) + + // Re-enable feature before device acknowledges clear command + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + // Run cron - should restore host to verified state + runRecoveryLockCron(t) + + // Host should be back to verified state (restored) + rlpStatus = getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusVerified, *rlpStatus.Status, "host should be restored to verified when feature is re-enabled") + + // Disable recovery lock password for cleanup + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + }) + + // ========================================================================= + // Test 9: Non-Apple Silicon host should not get recovery lock + // ========================================================================= + t.Run("Non-Apple Silicon host should not get recovery lock", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + // Create Intel Mac host (NOT Apple Silicon) + host, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) + host.CPUType = "x86_64" // Intel, not ARM + err := s.ds.UpdateHost(t.Context(), host) + require.NoError(t, err) + + // Run cron - should not send commands for Intel host + runRecoveryLockCron(t) + + // Intel host should not have recovery lock password + rlpStatus := getHostRecoveryLockStatus(host.ID) + assert.Nil(t, rlpStatus.Status, "Intel Mac should not get recovery lock") + + // Disable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + }) + + // ========================================================================= + // Test 10: Team-specific recovery lock password + // ========================================================================= + t.Run("Team-specific recovery lock password", func(t *testing.T) { + // Ensure global recovery lock is disabled + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + + // Create a team with recovery lock enabled + teamName := "RecoveryLockTeam-" + uuid.NewString()[:8] + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{Name: teamName}, http.StatusOK, &createTeamResp) + team := createTeamResp.Team + + // Enable recovery lock for the team + var modTeamResp teamResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{ + MDM: &fleet.TeamPayloadMDM{ + EnableRecoveryLockPassword: optjson.SetBool(true), + }, + }, http.StatusOK, &modTeamResp) + require.True(t, modTeamResp.Team.Config.MDM.EnableRecoveryLockPassword) + + // Verify activity was created + s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledRecoveryLockPasswords{}.ActivityName(), + fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, team.ID, teamName, team.ID, teamName), 0) + + // Create host and add to team + host, mdmClient := createAppleSiliconHost(t) + err := s.ds.AddHostsToTeam(t.Context(), fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID})) + require.NoError(t, err) + + // Run cron - should send SetRecoveryLock for team host + runRecoveryLockCron(t) + + // Host should be in pending state + rlpStatus := getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusPending, *rlpStatus.Status) + + // Acknowledge command + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + _, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // Verify host is verified + rlpStatus = getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusVerified, *rlpStatus.Status) + + // Create another host NOT in team - should not get recovery lock + host2, _ := createAppleSiliconHost(t) + runRecoveryLockCron(t) + rlpStatus2 := getHostRecoveryLockStatus(host2.ID) + assert.Nil(t, rlpStatus2.Status, "host not in team should not get recovery lock") + + // Disable team recovery lock + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{ + MDM: &fleet.TeamPayloadMDM{ + EnableRecoveryLockPassword: optjson.SetBool(false), + }, + }, http.StatusOK, &modTeamResp) + }) + + // ========================================================================= + // Test 11: Failed host state persists error message + // ========================================================================= + t.Run("Failed host state persists error message", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + host, mdmClient := createAppleSiliconHost(t) + + runRecoveryLockCron(t) + + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + + // Simulate specific error + errorMsg := "DeviceAlreadyHasPIN" + _, err = mdmClient.Err(cmd.CommandUUID, []mdm.ErrorChain{ + {ErrorCode: 12066, ErrorDomain: "MCMDMErrorDomain", LocalizedDescription: errorMsg}, + }) + require.NoError(t, err) + + // Verify error message is captured in detail + rlpStatus := getHostRecoveryLockStatus(host.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusFailed, *rlpStatus.Status) + assert.Contains(t, rlpStatus.Detail, errorMsg) + + // Disable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + }) + + // ========================================================================= + // Test 12: Get recovery lock password for non-Apple-Silicon host returns error + // ========================================================================= + t.Run("Get recovery lock password for non-Apple-Silicon host returns error", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + // Create Intel Mac host + host, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) + host.CPUType = "x86_64" + err := s.ds.UpdateHost(t.Context(), host) + require.NoError(t, err) + + // Try to get recovery lock password for Intel host - should fail with validation error + res := s.DoRaw("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/recovery_lock_password", host.ID), nil, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + assert.Contains(t, errMsg, "Apple Silicon") + + // Disable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + }) + + // ========================================================================= + // Test 13: Multiple hosts in batch + // ========================================================================= + t.Run("Multiple hosts processed in batch", func(t *testing.T) { + // Enable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": true}, + }, http.StatusOK, &appConfigResponse{}) + + // Create multiple hosts + var hosts []*fleet.Host + var mdmClients []*mdmtest.TestAppleMDMClient + for range 3 { + h, c := createAppleSiliconHost(t) + hosts = append(hosts, h) + mdmClients = append(mdmClients, c) + } + + // Run cron - all hosts should be in pending state + runRecoveryLockCron(t) + + for _, h := range hosts { + rlpStatus := getHostRecoveryLockStatus(h.ID) + require.NotNil(t, rlpStatus.Status, "host %d should have status", h.ID) + assert.Equal(t, fleet.RecoveryLockStatusPending, *rlpStatus.Status) + } + + // Acknowledge commands for all hosts + for _, c := range mdmClients { + cmd, err := c.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + _, err = c.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + + // All hosts should be verified + for _, h := range hosts { + rlpStatus := getHostRecoveryLockStatus(h.ID) + require.NotNil(t, rlpStatus.Status) + assert.Equal(t, fleet.RecoveryLockStatusVerified, *rlpStatus.Status) + } + + // Disable recovery lock password + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) + }) + + // Final cleanup: ensure recovery lock is disabled + s.DoJSON("PATCH", "/api/latest/fleet/config", map[string]any{ + "mdm": map[string]any{"enable_recovery_lock_password": false}, + }, http.StatusOK, &appConfigResponse{}) +} From ecee908157b385582ab0962e19a31f8a0213cf61 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:49:36 -0500 Subject: [PATCH 047/141] Bumping signoz resources for 100K hosts loadtest. (#41961) --- .../loadtesting/terraform/signoz/main.tf | 23 +++++++++---------- .../signoz/otel-collector-values.yaml | 23 ++++++++++--------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/infrastructure/loadtesting/terraform/signoz/main.tf b/infrastructure/loadtesting/terraform/signoz/main.tf index 2c88f2c2b1c..67008b24850 100644 --- a/infrastructure/loadtesting/terraform/signoz/main.tf +++ b/infrastructure/loadtesting/terraform/signoz/main.tf @@ -137,7 +137,7 @@ module "eks" { min_size = 1 max_size = 1 desired_size = 1 - instance_types = ["t3.2xlarge"] + instance_types = ["m6i.8xlarge"] } } @@ -255,7 +255,7 @@ resource "helm_release" "signoz" { # Clickhouse storage configuration set { name = "clickhouse.persistence.size" - value = "200Gi" + value = "600Gi" } set { @@ -279,49 +279,48 @@ resource "helm_release" "signoz" { # Default 100m CPU and 200Mi memory are way too low for high-volume telemetry set { name = "clickhouse.resources.requests.cpu" - value = "2000m" + value = "6000m" } set { name = "clickhouse.resources.requests.memory" - value = "4Gi" + value = "12Gi" } set { name = "clickhouse.resources.limits.cpu" - value = "4000m" + value = "12000m" } set { name = "clickhouse.resources.limits.memory" - value = "8Gi" + value = "24Gi" } # OTEL Collector resource configuration for loadtest set { name = "otelCollector.resources.requests.memory" - value = "8Gi" + value = "24Gi" } set { name = "otelCollector.resources.limits.memory" - value = "12Gi" + value = "36Gi" } set { name = "otelCollector.resources.requests.cpu" - value = "1000m" + value = "3000m" } set { name = "otelCollector.resources.limits.cpu" - value = "4000m" + value = "12000m" } - # Only need 1 replica since we have 1 LoadBalancer endpoint set { name = "otelCollector.replicaCount" - value = "1" + value = "3" } depends_on = [ diff --git a/infrastructure/loadtesting/terraform/signoz/otel-collector-values.yaml b/infrastructure/loadtesting/terraform/signoz/otel-collector-values.yaml index b97be1d5298..7183f4fdb67 100644 --- a/infrastructure/loadtesting/terraform/signoz/otel-collector-values.yaml +++ b/infrastructure/loadtesting/terraform/signoz/otel-collector-values.yaml @@ -31,16 +31,17 @@ otelCollector: processors: batch: - send_batch_size: 2000 + send_batch_size: 6000 + send_batch_max_size: 7500 timeout: 5s batch/meter: - send_batch_max_size: 25000 - send_batch_size: 20000 + send_batch_max_size: 75000 + send_batch_size: 60000 timeout: 1s memory_limiter: check_interval: 1s - limit_mib: 11264 - spike_limit_mib: 512 + limit_mib: 33792 + spike_limit_mib: 1536 signozspanmetrics/delta: metrics_exporter: signozclickhousemetrics latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s] @@ -72,13 +73,13 @@ otelCollector: max_elapsed_time: 300s sending_queue: enabled: true - num_consumers: 10 - queue_size: 10000 + num_consumers: 30 + queue_size: 15000 signozclickhousemetrics: timeout: 45s # Traces exporter must handle spans after tail_sampling clickhousetraces: - timeout: 120s + timeout: 60s retry_on_failure: enabled: true initial_interval: 5s @@ -86,8 +87,8 @@ otelCollector: max_elapsed_time: 300s sending_queue: enabled: true - num_consumers: 10 - queue_size: 10000 + num_consumers: 30 + queue_size: 15000 # Metadata exporter with increased timeout and retry support metadataexporter: @@ -114,7 +115,7 @@ otelCollector: exporters: [clickhousetraces, metadataexporter, signozmeter] metrics: receivers: [otlp] - processors: [batch] + processors: [memory_limiter, batch] exporters: [metadataexporter, signozclickhousemetrics, signozmeter] logs: receivers: [otlp] From f19cc81772ee2fead94a9f018068da5c97a384bb Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 19 Mar 2026 13:19:22 -0500 Subject: [PATCH 048/141] Website: automatically add case study links to the /customers page (#42088) Closes: https://github.com/fleetdm/fleet/issues/41799 Changes: - Added support for a new required meta tag for anonymous case study articles: `cardTitleForCustomersPage`. The value of this meta tag is used as the title of the automatically generated card link for the article on the /customers page. - Added support for a new meta tag for anonymous case study articles: `cardBodyForCustomersPage`. If provided, the card link for the article will use this value for the body text, if not provided, the card link will display the `articleTitle` meta tag value. - Updated the /customers page to automatically create card links for case study articles that have `useBasicArticleTemplate` and `cardTitleForCustomersPage` meta tags. --- articles/agritech-producer.md | 1 + articles/ai-security-company.md | 2 +- articles/banking-platform.md | 1 + articles/cannabis-technology-company.md | 2 + articles/cloud-data-platform.md | 1 + articles/collaboration-platform.md | 1 + articles/communications-platform.md | 1 + articles/computational-research-company.md | 1 + articles/consumer-electronics.md | 1 + articles/cybersecurity-company-1.md | 2 + articles/cybersecurity-company.md | 2 + articles/data-platform.md | 2 + ...h-fleet-gitops-and-skip-the-sync-server.md | 1 - articles/digital-bank.md | 1 + articles/electric-vehicle-manufacturer.md | 1 + articles/ev-manufacturer.md | 1 + articles/financial-data-company.md | 3 +- articles/financial-services-company-1.md | 1 + articles/financial-services-company.md | 4 +- articles/financial-services-platform.md | 1 + articles/financial-technology-company.md | 1 + ...y-strengthens-infrastructure-visibility.md | 2 + articles/fintech-company.md | 1 + articles/gaming-platform.md | 2 + articles/gaming-technology-company.md | 1 + articles/global-entertainment-company.md | 2 + articles/global-saas-company.md | 1 + articles/global-social-media-platform.md | 2 +- articles/global-technology-platform.md | 2 + .../global-workforce-management-company.md | 3 + .../healthcare-technology-organization.md | 1 + articles/identity-platform.md | 1 + articles/identity-security-company.md | 2 + articles/interactive-entertainment-company.md | 1 + articles/it-platform-provider.md | 3 +- articles/it-service-company.md | 1 + articles/it-service-provider.md | 1 + articles/journalism-nonprofit.md | 3 +- articles/medical-research-institution.md | 1 + articles/national-research-lab.md | 3 +- articles/national-research-organization.md | 2 + articles/online-gaming-platform.md | 1 + articles/online-marketplace.md | 2 + articles/open-source-organization.md | 3 +- articles/open-source-software-company.md | 1 + articles/open-source-technology-company.md | 1 + articles/robotics-company.md | 1 + articles/technology-platform.md | 1 + articles/workspace-software-company.md | 3 +- ...de-security-and-authentication-platform.md | 1 + website/api/controllers/view-testimonials.js | 22 ++ website/assets/styles/pages/testimonials.less | 3 + website/scripts/build-static-content.js | 4 + website/views/pages/testimonials.ejs | 257 +----------------- 54 files changed, 111 insertions(+), 255 deletions(-) diff --git a/articles/agritech-producer.md b/articles/agritech-producer.md index be0d908d1e5..997bce7e1b8 100644 --- a/articles/agritech-producer.md +++ b/articles/agritech-producer.md @@ -34,3 +34,4 @@ Fleet is the open-source endpoint management platform that gives you total contr + \ No newline at end of file diff --git a/articles/ai-security-company.md b/articles/ai-security-company.md index 8eb2d75f86a..a4b65ba8787 100644 --- a/articles/ai-security-company.md +++ b/articles/ai-security-company.md @@ -34,4 +34,4 @@ Fleet is the open-source endpoint management platform that gives you total contr - + diff --git a/articles/banking-platform.md b/articles/banking-platform.md index 0e25c5fadc9..d2903f59aff 100644 --- a/articles/banking-platform.md +++ b/articles/banking-platform.md @@ -36,3 +36,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/cannabis-technology-company.md b/articles/cannabis-technology-company.md index 278b915511b..80b688e3628 100644 --- a/articles/cannabis-technology-company.md +++ b/articles/cannabis-technology-company.md @@ -36,3 +36,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + diff --git a/articles/cloud-data-platform.md b/articles/cloud-data-platform.md index c0bffe971f4..7a4474debbe 100644 --- a/articles/cloud-data-platform.md +++ b/articles/cloud-data-platform.md @@ -77,3 +77,4 @@ The cloud data platform's adoption of Fleet Device Management exemplifies how mo + diff --git a/articles/collaboration-platform.md b/articles/collaboration-platform.md index 801c30792e6..e9fbefb6b23 100644 --- a/articles/collaboration-platform.md +++ b/articles/collaboration-platform.md @@ -79,3 +79,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + diff --git a/articles/communications-platform.md b/articles/communications-platform.md index 0f46b1bcab9..294e25029d8 100644 --- a/articles/communications-platform.md +++ b/articles/communications-platform.md @@ -38,3 +38,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + diff --git a/articles/computational-research-company.md b/articles/computational-research-company.md index 0ca80491cc2..4d046d9e563 100644 --- a/articles/computational-research-company.md +++ b/articles/computational-research-company.md @@ -72,3 +72,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + diff --git a/articles/consumer-electronics.md b/articles/consumer-electronics.md index 7cb5d97bf38..0caef470cd2 100644 --- a/articles/consumer-electronics.md +++ b/articles/consumer-electronics.md @@ -72,3 +72,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + diff --git a/articles/cybersecurity-company-1.md b/articles/cybersecurity-company-1.md index e29c4587332..36202036cde 100644 --- a/articles/cybersecurity-company-1.md +++ b/articles/cybersecurity-company-1.md @@ -74,3 +74,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + \ No newline at end of file diff --git a/articles/cybersecurity-company.md b/articles/cybersecurity-company.md index d77e43058f1..1957cc7c85e 100644 --- a/articles/cybersecurity-company.md +++ b/articles/cybersecurity-company.md @@ -84,3 +84,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + \ No newline at end of file diff --git a/articles/data-platform.md b/articles/data-platform.md index 34fa9e68d7f..9a930c6cb53 100644 --- a/articles/data-platform.md +++ b/articles/data-platform.md @@ -72,3 +72,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + \ No newline at end of file diff --git a/articles/deploy-santa-with-fleet-gitops-and-skip-the-sync-server.md b/articles/deploy-santa-with-fleet-gitops-and-skip-the-sync-server.md index aa91be98a75..cb18e64810b 100644 --- a/articles/deploy-santa-with-fleet-gitops-and-skip-the-sync-server.md +++ b/articles/deploy-santa-with-fleet-gitops-and-skip-the-sync-server.md @@ -71,4 +71,3 @@ The [next article](https://fleetdm.com/guides/how-we-deployed-santa-at-fleet) in - diff --git a/articles/digital-bank.md b/articles/digital-bank.md index bfedf7ccf08..b75d20d77c5 100644 --- a/articles/digital-bank.md +++ b/articles/digital-bank.md @@ -83,3 +83,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + diff --git a/articles/electric-vehicle-manufacturer.md b/articles/electric-vehicle-manufacturer.md index 8ad7aea1501..501829feda7 100644 --- a/articles/electric-vehicle-manufacturer.md +++ b/articles/electric-vehicle-manufacturer.md @@ -83,3 +83,4 @@ The decision to purchase Fleet was driven by the need for a more reliable, compr + \ No newline at end of file diff --git a/articles/ev-manufacturer.md b/articles/ev-manufacturer.md index d566f1f7922..89b10dbbb64 100644 --- a/articles/ev-manufacturer.md +++ b/articles/ev-manufacturer.md @@ -83,3 +83,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/financial-data-company.md b/articles/financial-data-company.md index 382dea372f8..9c00244a80a 100644 --- a/articles/financial-data-company.md +++ b/articles/financial-data-company.md @@ -91,4 +91,5 @@ Fleet is the single endpoint management platform for macOS, iOS, Android, Window - \ No newline at end of file + + \ No newline at end of file diff --git a/articles/financial-services-company-1.md b/articles/financial-services-company-1.md index d8341b7e088..8e713f89c91 100644 --- a/articles/financial-services-company-1.md +++ b/articles/financial-services-company-1.md @@ -75,3 +75,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/financial-services-company.md b/articles/financial-services-company.md index 7557c8d75c8..391f25b5cb2 100644 --- a/articles/financial-services-company.md +++ b/articles/financial-services-company.md @@ -70,4 +70,6 @@ The migration to [Fleet Device Management](https://fleetdm.com/device-management - \ No newline at end of file + + + \ No newline at end of file diff --git a/articles/financial-services-platform.md b/articles/financial-services-platform.md index 286c0c3bcc1..b2e336c61b2 100644 --- a/articles/financial-services-platform.md +++ b/articles/financial-services-platform.md @@ -36,3 +36,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/financial-technology-company.md b/articles/financial-technology-company.md index 434150afeff..92db49776c3 100644 --- a/articles/financial-technology-company.md +++ b/articles/financial-technology-company.md @@ -36,3 +36,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + diff --git a/articles/fintech-company-strengthens-infrastructure-visibility.md b/articles/fintech-company-strengthens-infrastructure-visibility.md index 1ff6e11cad8..f3e141b2bc9 100644 --- a/articles/fintech-company-strengthens-infrastructure-visibility.md +++ b/articles/fintech-company-strengthens-infrastructure-visibility.md @@ -72,3 +72,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + \ No newline at end of file diff --git a/articles/fintech-company.md b/articles/fintech-company.md index 7b87dcc834d..f16d33102f4 100644 --- a/articles/fintech-company.md +++ b/articles/fintech-company.md @@ -80,3 +80,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/gaming-platform.md b/articles/gaming-platform.md index 07faa573185..5778878f7a2 100644 --- a/articles/gaming-platform.md +++ b/articles/gaming-platform.md @@ -102,3 +102,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + \ No newline at end of file diff --git a/articles/gaming-technology-company.md b/articles/gaming-technology-company.md index 89317124b49..89c166329d0 100644 --- a/articles/gaming-technology-company.md +++ b/articles/gaming-technology-company.md @@ -36,3 +36,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + diff --git a/articles/global-entertainment-company.md b/articles/global-entertainment-company.md index a52d54c24a8..8d9655a7c4e 100644 --- a/articles/global-entertainment-company.md +++ b/articles/global-entertainment-company.md @@ -76,3 +76,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + diff --git a/articles/global-saas-company.md b/articles/global-saas-company.md index 6c4010241c3..ff11f5dc926 100644 --- a/articles/global-saas-company.md +++ b/articles/global-saas-company.md @@ -85,3 +85,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + diff --git a/articles/global-social-media-platform.md b/articles/global-social-media-platform.md index 00e8de68091..f8669d0dd92 100644 --- a/articles/global-social-media-platform.md +++ b/articles/global-social-media-platform.md @@ -78,4 +78,4 @@ Transitioning to Fleet provided the platform with a strategic solution that addr - + diff --git a/articles/global-technology-platform.md b/articles/global-technology-platform.md index faa821469bb..53b9731f964 100644 --- a/articles/global-technology-platform.md +++ b/articles/global-technology-platform.md @@ -113,3 +113,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + \ No newline at end of file diff --git a/articles/global-workforce-management-company.md b/articles/global-workforce-management-company.md index 94ee93c74fd..19551457ec6 100644 --- a/articles/global-workforce-management-company.md +++ b/articles/global-workforce-management-company.md @@ -66,3 +66,6 @@ By switching to Fleet, this global workforce management company gained a powerfu + + + diff --git a/articles/healthcare-technology-organization.md b/articles/healthcare-technology-organization.md index 6ecd2e3cee5..619bf2a0068 100644 --- a/articles/healthcare-technology-organization.md +++ b/articles/healthcare-technology-organization.md @@ -34,3 +34,4 @@ Fleet is the open-source endpoint management platform that gives you total contr + \ No newline at end of file diff --git a/articles/identity-platform.md b/articles/identity-platform.md index 29a35061b60..d30f766c3f0 100644 --- a/articles/identity-platform.md +++ b/articles/identity-platform.md @@ -76,3 +76,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + diff --git a/articles/identity-security-company.md b/articles/identity-security-company.md index ae6ad820192..6cf070515f8 100644 --- a/articles/identity-security-company.md +++ b/articles/identity-security-company.md @@ -74,3 +74,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + \ No newline at end of file diff --git a/articles/interactive-entertainment-company.md b/articles/interactive-entertainment-company.md index 1d6e8b6fa83..67e5f01375d 100644 --- a/articles/interactive-entertainment-company.md +++ b/articles/interactive-entertainment-company.md @@ -74,3 +74,4 @@ By switching to Fleet, it solved their critical challenges in device management, + \ No newline at end of file diff --git a/articles/it-platform-provider.md b/articles/it-platform-provider.md index c494b759f8b..2d580b54ec7 100644 --- a/articles/it-platform-provider.md +++ b/articles/it-platform-provider.md @@ -33,4 +33,5 @@ Fleet is the open-source endpoint management platform that gives you total contr - \ No newline at end of file + + \ No newline at end of file diff --git a/articles/it-service-company.md b/articles/it-service-company.md index c35bbfb152a..74d02a90579 100644 --- a/articles/it-service-company.md +++ b/articles/it-service-company.md @@ -70,3 +70,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/it-service-provider.md b/articles/it-service-provider.md index 88f48a92573..7b73b246b27 100644 --- a/articles/it-service-provider.md +++ b/articles/it-service-provider.md @@ -40,3 +40,4 @@ Fleet is the open-source endpoint management platform that gives you total contr + diff --git a/articles/journalism-nonprofit.md b/articles/journalism-nonprofit.md index d0ee7e5927d..4ccaebeedba 100644 --- a/articles/journalism-nonprofit.md +++ b/articles/journalism-nonprofit.md @@ -33,4 +33,5 @@ Fleet is the open-source endpoint management platform that gives you total contr - \ No newline at end of file + + \ No newline at end of file diff --git a/articles/medical-research-institution.md b/articles/medical-research-institution.md index 6935e6d72a9..d316b8506ad 100644 --- a/articles/medical-research-institution.md +++ b/articles/medical-research-institution.md @@ -74,3 +74,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/national-research-lab.md b/articles/national-research-lab.md index 67e86672b78..5ae6379ceec 100644 --- a/articles/national-research-lab.md +++ b/articles/national-research-lab.md @@ -71,5 +71,6 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na - + + diff --git a/articles/national-research-organization.md b/articles/national-research-organization.md index 84e2d40ac94..dfcfb1e0fdd 100644 --- a/articles/national-research-organization.md +++ b/articles/national-research-organization.md @@ -76,3 +76,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + \ No newline at end of file diff --git a/articles/online-gaming-platform.md b/articles/online-gaming-platform.md index c553753a5f6..4719b6f92ca 100644 --- a/articles/online-gaming-platform.md +++ b/articles/online-gaming-platform.md @@ -79,3 +79,4 @@ By adopting Fleet for server observability, they've successfully addressed scala + \ No newline at end of file diff --git a/articles/online-marketplace.md b/articles/online-marketplace.md index faa66afd818..5419b519c19 100644 --- a/articles/online-marketplace.md +++ b/articles/online-marketplace.md @@ -77,3 +77,5 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + + \ No newline at end of file diff --git a/articles/open-source-organization.md b/articles/open-source-organization.md index 2af438d97b0..f3baa494e49 100644 --- a/articles/open-source-organization.md +++ b/articles/open-source-organization.md @@ -33,4 +33,5 @@ Fleet is the open-source endpoint management platform that gives you total contr - \ No newline at end of file + + \ No newline at end of file diff --git a/articles/open-source-software-company.md b/articles/open-source-software-company.md index 660ae828aa0..d81ab087f44 100644 --- a/articles/open-source-software-company.md +++ b/articles/open-source-software-company.md @@ -72,3 +72,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/open-source-technology-company.md b/articles/open-source-technology-company.md index bd5fd937be5..423b7131a43 100644 --- a/articles/open-source-technology-company.md +++ b/articles/open-source-technology-company.md @@ -72,3 +72,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/robotics-company.md b/articles/robotics-company.md index 969b5f0aab9..2babd32977b 100644 --- a/articles/robotics-company.md +++ b/articles/robotics-company.md @@ -38,3 +38,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/technology-platform.md b/articles/technology-platform.md index f63020d506d..f7b8b5c3afc 100644 --- a/articles/technology-platform.md +++ b/articles/technology-platform.md @@ -88,3 +88,4 @@ Fleet offers total deployment flexibility: on-premises, air-gapped, container-na + \ No newline at end of file diff --git a/articles/workspace-software-company.md b/articles/workspace-software-company.md index ef9fdeb3dae..892b5c9d7df 100644 --- a/articles/workspace-software-company.md +++ b/articles/workspace-software-company.md @@ -35,4 +35,5 @@ Fleet is the open-source endpoint management platform that gives you total contr - \ No newline at end of file + + diff --git a/articles/worldwide-security-and-authentication-platform.md b/articles/worldwide-security-and-authentication-platform.md index 46bfa3ddd46..bd157e8edbd 100644 --- a/articles/worldwide-security-and-authentication-platform.md +++ b/articles/worldwide-security-and-authentication-platform.md @@ -91,3 +91,4 @@ To learn more about how Fleet can support your organization, visit [fleetdm.com/ + diff --git a/website/api/controllers/view-testimonials.js b/website/api/controllers/view-testimonials.js index 7e55d2dec9f..3636e27dc5c 100644 --- a/website/api/controllers/view-testimonials.js +++ b/website/api/controllers/view-testimonials.js @@ -21,6 +21,9 @@ module.exports = { if (!_.isObject(sails.config.builtStaticContent) || !_.isArray(sails.config.builtStaticContent.testimonials) || !sails.config.builtStaticContent.compiledPagePartialsAppPath) { throw {badConfig: 'builtStaticContent.testimonials'}; } + if (!_.isObject(sails.config.builtStaticContent) || !_.isArray(sails.config.builtStaticContent.markdownPages) || !sails.config.builtStaticContent.compiledPagePartialsAppPath) { + throw {badConfig: 'builtStaticContent.markdownPages'}; + } // Get testimonials for the page contents let testimonials = _.clone(sails.config.builtStaticContent.testimonials); @@ -66,10 +69,29 @@ module.exports = { return testimonial.youtubeVideoUrl; }); + // Get all of the case study articles. + let caseStudies = sails.config.builtStaticContent.markdownPages.filter((page)=>{ + if(_.startsWith(page.url, '/case-study/')) { + return page; + } + }); + + // Only show case studies that have `useBasicArticleTemplate` and cardTitleForCustomersPage` meta tags + let caseStudiesToCreateLinksFor = caseStudies.filter((article)=>{ + if(article.meta.useBasicArticleTemplate && article.meta.cardTitleForCustomersPage){ + return article; + } + }); + + // Sort the case study articles by the lowercase cardTitleForCustomersPage meta tag value. + caseStudiesToCreateLinksFor = _.sortBy(caseStudiesToCreateLinksFor, (article)=>{ + return article.meta.cardTitleForCustomersPage.toLowerCase(); + }); return { testimonials: filteredTestimonialsForThisPage, testimonialsWithVideoLinks, + caseStudiesToCreateLinksFor, }; } diff --git a/website/assets/styles/pages/testimonials.less b/website/assets/styles/pages/testimonials.less index 1f6f9e44956..d3c37a27d60 100644 --- a/website/assets/styles/pages/testimonials.less +++ b/website/assets/styles/pages/testimonials.less @@ -177,6 +177,9 @@ cursor: pointer; box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.1); } + p { + margin-bottom: 0px; + } a { justify-self: flex-end; } diff --git a/website/scripts/build-static-content.js b/website/scripts/build-static-content.js index 0edea023e17..f94b422d7c7 100644 --- a/website/scripts/build-static-content.js +++ b/website/scripts/build-static-content.js @@ -733,6 +733,10 @@ module.exports = { } } } + } else { + if(!embeddedMetadata.cardTitleForCustomersPage) { + throw new Error(`Failed compiling markdown content: A case study article is missing a "cardTitleForCustomersPage" meta tag at "${path.join(topLvlRepoPath, pageSourcePath)}". To resolve, add a "cardTitleForCustomersPage" meta tag with the title of this article when it is linked to from the /customers page. (e.g., "AI security company")`); + } } } // If this is a whitepaper article, we'll check to make sure it has a whitepaperFilename and TODO metatags diff --git a/website/views/pages/testimonials.ejs b/website/views/pages/testimonials.ejs index 9f458c338dd..4635e4e84bd 100644 --- a/website/views/pages/testimonials.ejs +++ b/website/views/pages/testimonials.ejs @@ -143,251 +143,18 @@

Thumbtack migrates more than 90% of Macs with no IT intervention.

Read their story -
-

Agritech producer

-

Agritech producer replaces manual tracking across 273 devices.

- Read their story -
-
-

AI security company

-

AI security company runs live queries to verify CVEs in seconds.

- Read their story -
-
-

Banking platform

-

Banking platform guarantees script execution and audit-ready compliance.

- Read their story -
-
-

Cannabis technology company

-

Cannabis technology company consolidates Jamf and Intune with Fleet.

- Read their story -
-
-

Cloud data platform

-

Cloud-based data leader chooses Fleet for orchestration.

- Read their story -
-
-

Collaboration platform

-

Global collaboration platform consolidates device management with Fleet.

- Read their story -
-
-

Communications platform

-

Communications platform unifies device management across 3,000 devices.

- Read their story -
-
-

Computational research company

-

Computational research company unifies endpoint management with Fleet.

- Read their story -
-
-

Consumer electronics

-

Consumer electronics company simplifies cross-platform management with Fleet.

- Read their story -
-
-

Cybersecurity company

-

A cybersecurity company improves device visibility.

- Read their story -
-
-

Cybersecurity company

-

Cybersecurity company improves Linux management with Fleet, replacing legacy tools.

- Read their story -
-
-

Data platform

-

Data platform company cuts $5–6M in hardware costs with API-driven device management.

- Read their story -
-
-

Digital bank

-

Digital bank strengthens security and compliance with Fleet.

- Read their story -
-
-

Electric vehicle manufacturer

-

Vehicle manufacturer transitions to Fleet for endpoint security.

- Read their story -
-
-

EV manufacturer

-

EV manufacturer brings Linux workstations under centralized management.

- Read their story -
-
-

Financial data company

-

Financial data company scales endpoint visibility with Fleet.

- Read their story -
-
-

Financial services company

-

Financial services company migrates to Fleet for MDM and next-gen change management

- Read their story -
-
-

Financial services company

-

Financial services company reduces tool sprawl with Fleet.

- Read their story -
-
-

Financial services platform

-

Financial services platform manages 6,000+ hosts with continuous compliance visibility.

- Read their story -
-
-

Financial technology company

-

Financial technology company manages 15,000 devices with GitOps

- Read their story -
-
-

Financial technology company

-

A fintech company manages a global remote workforce with Fleet.

- Read their story -
-
-

Financial technology company

-

Fleet gives a fintech company real-time infrastructure visibility across laptops and cloud systems.

- Read their story -
-
-

Gaming platform gains production visibility

-

How a gaming platform uses Fleet to gain infrastructure visibility across 135,000+ hosts.

- Read their story -
-
-

Gaming technology company

-

Gaming technology company runs GitOps-driven device management on-prem.

- Read their story -
-
-

Global collaboration platform

-

Global collaboration platform consolidates device management with Fleet.

- Read their story -
-
-

Global entertainment company

-

Global entertainment company manages thousands of devices with Fleet.

- Read their story -
-
-

Global technology platform

-

Global technology platform improves vulnerability intelligence with Fleet.

- Read their story -
-
-

Global SaaS company

-

Global SaaS company modernizes device management with Fleet.

- Read their story -
-
-

Global social media platform

-

Global social media platform migrates to Fleet.

- Read their story -
-
-

Global workforce management software

-

A global workforce management company achieved compliance and clarity with Fleet β€” keeping shift work in sync.

- Read their story -
-
-

Healthcare technology organization

-

Enforcing security policies in minutes across a regulated healthcare environment

- Read their story -
-
-

Identity platform

-

An identity platform improves Linux visibility.

- Read their story -
-
-

Identity security company

-

Identity security company unifies macOS and Windows device management.

- Read their story -
-
-

IT platform provider

-

IT platform provider automates patching across thousands of Mac, Windows, and Linux devices.

- Read their story -
-
-

IT service provider

-

IT service provider scales to 8,000+ devices with GitOps.

- Read their story -
-
-

Interactive entertainment company

-

Leading interactive entertainment company adopts Fleet for MDM.

- Read their story -
-
-

Journalism nonprofit

-

Journalism nonprofit manages Mac and Linux devices with GitOps.

- Read their story -
-
-

Medical research institution

-

A medical research institution brings Linux devices into compliance.

- Read their story -
-
-

National research organization

-

A research organization uses Fleet to automate Linux patching and improve device visibility.

- Read their story -
-
-

National research lab

-

National research lab scales host visibility with Fleet.

- Read their story -
-
-

Online gaming platform

-

Large gaming company enhances server observability with Fleet.

- Read their story -
-
-

Online marketplace

-

An online marketplace replaces complex device tools.

- Read their story -
-
-

Open-source organization

-

Open-source organization manages 1,556 devices with real-time compliance.

- Read their story -
-
-

Open-source software company

-

Open-source software company closes the Linux gap in device management with Fleet.

- Read their story -
-
-

Open-source technology company

-

Open-source technology company scales endpoint management with Fleet.

- Read their story -
-
-

Robotics company

-

Robotics company unifies Mac, Windows, Linux, and Android devices.

- Read their story -
-
-

Technology platform

-

Technology company manages 15,000 iPads with Fleet.

- Read their story -
-
-

Security and authentication platform

-

Worldwide security and authentication platform chooses Fleet for Linux management.

- Read their story -
-
-

Workspace software company

-

Workspace software company consolidates Kandji and Intune across 1,465 devices.

- Read their story -
+ <% /* Automatically created cards for anonymous case studies */%> + <% for(let article of caseStudiesToCreateLinksFor) { %> +
+

<%- article.meta.cardTitleForCustomersPage %>

+ <% if(article.meta.cardBodyForCustomersPage){ %> +

<%- article.meta.cardBodyForCustomersPage %>

+ <% } else {%> +

<%- article.meta.articleTitle %>.

+ <% }%> + Read their story +
+ <% } %>
From c14569cfbfeb0d24587459e0f7df0d2c1b7bcc22 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Thu, 19 Mar 2026 14:37:56 -0400 Subject: [PATCH 049/141] lint --- .../CertificateAuthorities/components/NDESForm/NDESForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/NDESForm/NDESForm.tsx b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/NDESForm/NDESForm.tsx index 81ecb34a01f..ffa3f662ced 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/NDESForm/NDESForm.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/CertificateAuthorities/components/NDESForm/NDESForm.tsx @@ -88,7 +88,7 @@ const NDESForm = ({ onChange={onInputChange} parseTarget placeholder="username@example.microsoft.com" - helpText="For NDES, this is the username in the down-level logon name + helpText="For NDES, this is the username in the down-level logon name format required to log in to the SCEP admin page. Okta generates this for you." /> For NDES, the password required to log in to the{" "} - Network Device Enrollment Service page. Okta generates this + Network Device Enrollment Service page. Okta generates this for you. } From 0b152049141797977f7aa63e8c15a216dd733b36 Mon Sep 17 00:00:00 2001 From: Luke Heath Date: Thu, 19 Mar 2026 13:40:01 -0500 Subject: [PATCH 050/141] Remove fleetd components release QA instructions (#42093) --- handbook/engineering/README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/handbook/engineering/README.md b/handbook/engineering/README.md index 83da261f5fa..4f7147b9e3b 100644 --- a/handbook/engineering/README.md +++ b/handbook/engineering/README.md @@ -255,12 +255,6 @@ If an announcement is found for either data source that may impact data feed ava If a new OS version is missing, [file a bug](https://github.com/fleetdm/fleet/issues/new?assignees=&labels=bug%2C%3Areproduce&projects=&template=bug-report.md&title=). -6. [Fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd) components -- Check for code changes to [Orbit](https://github.com/fleetdm/fleet/blob/main/orbit/) or [Desktop](https://github.com/fleetdm/fleet/tree/main/orbit/cmd/desktop) since the last `orbit-*` tag was published. -- Check for code changes to the [fleetd-chrome extension](https://github.com/fleetdm/fleet/tree/main/ee/fleetd-chrome) since the last `fleetd-chrome-*` tag was published. - -If code changes are found for any `fleetd` components, create a new release QA issue to update `fleetd`. Delete the top section for Fleet core, and retain the bottom section for `fleetd`. Populate the necessary version changes for each `fleetd` component. - ### Indicate your product group is release-ready From b99871d01d1e61dbd1345e0082583d3216aedd0e Mon Sep 17 00:00:00 2001 From: George Karr Date: Thu, 19 Mar 2026 14:08:20 -0500 Subject: [PATCH 051/141] Adding backport check script and notes on how to use it (#40895) --- tools/release/README.md | 37 ++++++++ tools/release/backport-check.sh | 163 ++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100755 tools/release/backport-check.sh diff --git a/tools/release/README.md b/tools/release/README.md index 2c0c878c9e9..598ff2ae7f7 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -162,3 +162,40 @@ The release script will announce the patch in the #general channel. Open the Fle **9. Conclude the milestone** Complete the [conclude the milestone ritual](https://fleetdm.com/handbook/engineering#conclude-current-milestone). + +## Using the Backport check script + +```sh +./tools/release/backport-check.sh fleet-v4.81.0 rc-patch-fleet-v4.81.1 rc-minor-fleet-v4.82.0 +``` + +Example output +``` +Indexing rc-minor-fleet-v4.82.0 since merge-base with fleet-v4.81.0 (6e9d46202e9b54e1b83542179f40ed880586d3f4)... + +=== INCLUDED (present on rc-minor-fleet-v4.82.0) === +STATUS PATCH_SHA MINOR_SHA MATCH SUBJECT +-------- ------------ ------------ -------- ---------------------------------------- +INCLUDED 8798f6cee5ea b1d0a5c2da8c subject End user UI: Update logo loading spinner styling (#39234) +INCLUDED 67c2d503f23d fa4b7426f1db subject End user /enroll page for macOS: Download button should have semi-bold font weight (#39301) +INCLUDED 1f09d64f4df7 fac6ca5f2afd subject Add ellipsis to cut-off placeholder text in search fields (#39112) +INCLUDED a3c6f7e3a1c6 ba1862595e4e subject Add route for Microsoft Entra home page (for tenant ID) (#39216) +... +INCLUDED 4ec78002e0d6 3b825449757f subject Set secure cookie in SSO callback (#40765) (#40806) + +=== MISSING (not found on rc-minor-fleet-v4.82.0) === +STATUS PATCH_SHA SUBJECT +-------- ------------ ---------------------------------------- +MISSING cb08454ddfef improve windows resending (#40365) +MISSING 3ef6f40a10fc Batch select query in CleanupExcessQueryResultRows (#40491) +MISSING 9e64a65f29c9 Improved validation for packages (#40407) +MISSING 5df0a554fe1d Add migration to update host_certificates_template UUID column size (… (#40709) +MISSING 82890f883369 Exorcise edited_enroll_secrets from v4.81.1 (#40714) +MISSING 1adbfb89429b Cherry-pick: Remove "do not enqueue setup experience items >24 hours after enrollment" logic for macOS hosts (#40739) (#40748) +MISSING ac068800375a Adding changes for Fleet v4.81.1 (#40704) +MISSING 729c324074c3 Avoid panics on VPP install command errors when command not initiated by Fleet VPP install -> 4.81.0 (#40395) +``` + +For each item in missing you can verify manually by opening the PR's at the end of each line to trace back to the original issue and validate if a related PR made it into the minor branch you are targeting. + +To include any code missed checkout the minor branch, branch off into a new branch to pull them in and then `git cherry-pick ` to add the missing code. Resolve any conflicts and open a PR based off the minor branch. diff --git a/tools/release/backport-check.sh b/tools/release/backport-check.sh new file mode 100755 index 00000000000..ff86506ec9c --- /dev/null +++ b/tools/release/backport-check.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +# backport-check.sh +# +# For each commit on PATCH_BRANCH since BASE, report whether it's INCLUDED in MINOR_BRANCH. +# INCLUDED means either: +# 1) same patch-id exists on MINOR_BRANCH (best: true cherry-pick equivalence) +# 2) normalized subject exists on MINOR_BRANCH (fallback for "Cherry pick"/"CP"/etc message variants) +# +# Output: +# - An "Included" section with the matching MINOR SHA and match method +# - A "Missing" section at the bottom for review +# +# Usage: +# ./backport-check.sh +# +# Example: +# ./backport-check.sh fleet-v4.81.0 rc-patch-fleet-v4.81.1 rc-minor-fleet-v4.82.0 + +BASE="${1:?base ref required (e.g. fleet-v4.81.0)}" +PATCH_BRANCH="${2:?patch branch required}" +MINOR_BRANCH="${3:?minor branch required}" + +git fetch --all --prune >/dev/null 2>&1 || true + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +normalize_subject_awk=' +function norm(s) { + # Trim leading whitespace + gsub(/^[[:space:]]+/, "", s) + + # Strip CP / CP: (common shorthand) + sub(/^[Cc][Pp][[:space:]]*:[[:space:]]*/, "", s) + sub(/^[Cc][Pp][[:space:]]+/, "", s) + + # Strip Cherry-pick / Cherry pick, with or without ":", and optionally "to " + # Examples handled: + # "Cherry-pick: foo" + # "Cherry pick: foo" + # "Cherry pick foo" + # "Cherry-pick to rc-minor... foo" + # "Cherry pick to rc-minor... foo" + if (match(s, /^[Cc]herry[- ]pick([[:space:]]*:[[:space:]]*|[[:space:]]+)(to[[:space:]]+[^[:space:]]+[[:space:]]+)?/)) { + s = substr(s, RLENGTH+1) + } + + # Strip repeated trailing " (#12345)" blocks (PR references) at end + while (sub(/[[:space:]]*\(#([0-9]+)\)[[:space:]]*$/, "", s)) {} + + # Collapse whitespace + gsub(/[[:space:]]+/, " ", s) + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s +} +{ print norm($0) } +' + +# Reasonable start point on MINOR_BRANCH: where it diverged from BASE. +MB="$(git merge-base "$MINOR_BRANCH" "$BASE")" + +echo "Indexing $MINOR_BRANCH since merge-base with $BASE ($MB)..." >&2 + +# Collect minor SHAs +git rev-list "$MB..$MINOR_BRANCH" > "$tmp/minor_revlist" + +# Build: patch-id -> minor sha (first hit wins) +# File format: " " +: > "$tmp/minor_patch_map" +while IFS= read -r h; do + pid="$( + git show "$h" --pretty=format: --patch \ + | git patch-id --stable \ + | awk '{print $1}' + )" + # Avoid duplicates (keep first sha we see for that patch-id) + if ! grep -Fq "$pid " "$tmp/minor_patch_map"; then + printf "%s %s\n" "$pid" "$h" >> "$tmp/minor_patch_map" + fi +done < "$tmp/minor_revlist" + +# Build: normalized subject -> minor sha (first hit wins) +# File format: "\t" +git log --format='%H%x09%s' "$MB..$MINOR_BRANCH" \ + | while IFS=$'\t' read -r sha subj; do + ns="$(printf "%s\n" "$subj" | awk "$normalize_subject_awk")" + printf "%s\t%s\n" "$ns" "$sha" + done \ + | awk -F'\t' '!seen[$1]++ { print }' \ + > "$tmp/minor_subject_map" + +# Patch-branch commits to check (chronological) +git log --reverse --format='%H%x09%s' "$BASE..$PATCH_BRANCH" > "$tmp/patch_commits.tsv" + +# Output buckets +included_out="$tmp/included.tsv" +missing_out="$tmp/missing.tsv" +: > "$included_out" +: > "$missing_out" + +# TSV formats: +# included: status,patch_sha,minor_sha,method,subject +# missing: status,patch_sha,subject +while IFS=$'\t' read -r sha subj; do + norm_subj="$(printf "%s\n" "$subj" | awk "$normalize_subject_awk")" + + patchid="$( + git show "$sha" --pretty=format: --patch \ + | git patch-id --stable \ + | awk '{print $1}' + )" + + # 1) patch-id match + minor_sha="$( + grep -F "^$patchid " "$tmp/minor_patch_map" 2>/dev/null | head -n1 | awk '{print $2}' + )" || true + + if [[ -n "${minor_sha:-}" ]]; then + printf "INCLUDED\t%s\t%s\tpatch-id\t%s\n" "$sha" "$minor_sha" "$subj" >> "$included_out" + continue + fi + + # 2) normalized subject match + minor_sha="$( + awk -F'\t' -v k="$norm_subj" '$1==k {print $2; exit}' "$tmp/minor_subject_map" 2>/dev/null + )" || true + + if [[ -n "${minor_sha:-}" ]]; then + printf "INCLUDED\t%s\t%s\tsubject\t%s\n" "$sha" "$minor_sha" "$subj" >> "$included_out" + else + printf "MISSING\t%s\t%s\n" "$sha" "$subj" >> "$missing_out" + fi +done < "$tmp/patch_commits.tsv" + +# Pretty print +echo +echo "=== INCLUDED (present on $MINOR_BRANCH) ===" +printf "%-9s %-12s %-12s %-8s %s\n" "STATUS" "PATCH_SHA" "MINOR_SHA" "MATCH" "SUBJECT" +printf "%-9s %-12s %-12s %-8s %s\n" "--------" "------------" "------------" "--------" "----------------------------------------" + +if [[ -s "$included_out" ]]; then + while IFS=$'\t' read -r status psha msha method subject; do + printf "%-9s %.12s %.12s %-8s %s\n" "$status" "$psha" "$msha" "$method" "$subject" + done < "$included_out" +else + echo "(none)" +fi + +echo +echo "=== MISSING (not found on $MINOR_BRANCH) ===" +printf "%-9s %-12s %s\n" "STATUS" "PATCH_SHA" "SUBJECT" +printf "%-9s %-12s %s\n" "--------" "------------" "----------------------------------------" + +if [[ -s "$missing_out" ]]; then + while IFS=$'\t' read -r status psha subject; do + printf "%-9s %.12s %s\n" "$status" "$psha" "$subject" + done < "$missing_out" +else + echo "(none)" +fi From 357d280c4a86f6fbb64dad768f8fa8af56d4ae62 Mon Sep 17 00:00:00 2001 From: Noah Talerman <47070608+noahtalerman@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:15:00 -0400 Subject: [PATCH 052/141] Renaming: API reference (#41942) For the following issue: - #41419 - @noahtalerman: Also remove old bits about Fleet 4.0.0 --------- Co-authored-by: Rachael Shaw --- docs/REST API/rest-api.md | 1002 ++++++++++++++++++++++--------------- 1 file changed, 585 insertions(+), 417 deletions(-) diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index b84d5bbaefe..728d1272c68 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -11,13 +11,13 @@ - [Commands](#commands) - [Integrations](#integrations-1) - [Policies](#policies) -- [Queries](#queries) +- [Reports](#reports) - [Schedule (deprecated)](#schedule) - [Scripts](#scripts) - [Sessions](#sessions) - [Software](#software) - [Targets](#targets) -- [Teams](#teams) +- [Fleets](#fleets) - [Translator](#translator) - [Users](#users) - [Custom variables](#custom-variables) @@ -102,7 +102,8 @@ Authenticates the user with the specified credentials. Use the token returned fr "sso_enabled": false, "mfa_enabled": false, "global_role": "admin", - "teams": [] + "teams": [], + "fleets": [] }, "token": "{your token}" } @@ -326,7 +327,8 @@ Retrieves the user data for the authenticated user. "force_password_reset": false, "gravatar_url": "", "sso_enabled": false, - "teams": [] + "fleets": [], + "fleets": [] }, "available_teams" : [ { @@ -335,6 +337,13 @@ Retrieves the user data for the authenticated user. "description": "Employee workstations" } ], + "available_fleets" : [ + { + "id": 1, + "name": "Workstations", + "description": "Employee workstations" + } + ] } ``` @@ -375,7 +384,8 @@ Resets the password of the authenticated user. Requires that `force_password_res "gravatar_url": "", "sso_enabled": false, "global_role": "admin", - "teams": [] + "teams": [], + "fleets": [] } } ``` @@ -556,10 +566,13 @@ Returns a list of the activities that have been performed in Fleet. For a compre "actor_gravatar": "", "actor_email": "name@example.com", "type": "created_team", + "type": "created_fleet", "fleet_initiated": false, "details": { "team_id": 2, - "team_name": "Apples" + "fleet_id": 2, + "team_name": "Apples", + "fleet_name": "Apples" } }, { @@ -728,7 +741,7 @@ Object with the following structure: ### Add certificate template -Add a certificate template to deploy a certificate to all hosts on the team. Fleet currently supports adding certificates for Android that are issued from a custom [SCEP](https://en.wikipedia.org/wiki/Simple_Certificate_Enrollment_Protocol) certificate authority. +Add a certificate template to deploy a certificate to all hosts on the fleet. Fleet currently supports adding certificates for Android that are issued from a custom [SCEP](https://en.wikipedia.org/wiki/Simple_Certificate_Enrollment_Protocol) certificate authority. `POST /api/v1/fleet/certificates` @@ -737,7 +750,7 @@ Add a certificate template to deploy a certificate to all hosts on the team. Fle | Name | Type | In | Description | | -------- | ------- | ---- | ------------------------------------------- | | name | string | body | **Required.** The name of the certificate. Name can be used as certificate alias to reference in configuration profiles. | -| team_id | string | body | _Available in Fleet Premium_. The ID of the team to add profiles to. | +| fleet_id | string | body | _Available in Fleet Premium_. The ID of the fleet to add profiles to. | | certificate_authority_id | integer | body | **Required.** The certificate authority (CA) ID to issue certificate from. Currently, only custom SCEP CA is supported. To get ID use [List certificate authorities](#list-certificate-authorities-cas). | | subject_name | string | body |**Required** The certificate's subject name (SN). Separate subject fields by a "/". For example: "/CN=john@example.com/O=Acme Inc.". | @@ -751,6 +764,7 @@ Add a certificate template to deploy a certificate to all hosts on the team. Fle { "name": "wifi-certificate", "team_id": 1, + "fleet_id": 1, "certificate_authority_id": 1, "subject_name": "/CN=$FLEET_VAR_HOST_END_USER_IDP_USERNAME/OU=$FLEET_VAR_HOST_UUID/ST=$FLEET_VAR_HOST_HARDWARE_SERIAL" } @@ -913,7 +927,7 @@ List certificate added to Fleet. Currently, they can only be added via GitOps. | Name | Type | In | Description | | ----------| ------- | ---- | -------------------------------------------------------------- | -| team | string | query | _Available in Fleet Premium_. The team ID to filter profiles. | +| fleet | string | query | _Available in Fleet Premium_. The fleet ID to filter profiles. | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | @@ -1377,8 +1391,8 @@ Retrieves the specified carve block. This endpoint retrieves the data that was c - [Update configuration](#update-configuration) - [Get global enroll secrets](#get-global-enroll-secrets) - [Update global enroll secrets](#update-global-enroll-secrets) -- [Get team enroll secrets](#get-team-enroll-secrets) -- [Update team enroll secrets](#update-team-enroll-secrets) +- [Get fleet enroll secrets](#get-fleet-enroll-secrets) +- [Update fleet enroll secrets](#update-fleet-enroll-secrets) - [Get version](#get-version) The Fleet server exposes API endpoints that handle the configuration of Fleet as well as endpoints that manage enroll secret operations. These endpoints require prior authentication, you so you'll need to log in before calling any of the endpoints documented below. @@ -1716,7 +1730,7 @@ Modifies the Fleet's configuration with the supplied information. | sso_settings | object | body | See [sso_settings](#sso-settings). | | host_expiry_settings | object | body | See [host_expiry_settings](#host-expiry-settings). | | activity_expiry_settings | object | body | See [activity_expiry_settings](#activity-expiry-settings). | -| agent_options | objects | body | The agent_options spec that is applied to all hosts. In Fleet 4.0.0 the `api/v1/fleet/spec/osquery_options` endpoints were removed. | +| agent_options | objects | body | The agent_options spec that is applied to all hosts. | | fleet_desktop | object | body | See [fleet_desktop](#fleet-desktop). | | webhook_settings | object | body | See [webhook_settings](#webhook-settings). | | integrations | object | body | See [integrations](#integrations). | @@ -2410,8 +2424,8 @@ When updating conditional access config, all `conditional_access` fields must ei | windows_enabled_and_configured | boolean | Enables Windows MDM support. | | windows_entra_tenant_ids | array | _Available in Fleet Premium._ IDs of Microsoft Entra tenants to connect to Fleet, to enable automatic (Autopilot) and manual enrollment by end users (**Settings** > **Accounts** > **Access work or school**Β on Windows). Find your **Tenant ID**, on [**Microsoft Entra ID** > **Home**](https://entra.microsoft.com/#home). | | enable_turn_on_windows_mdm_manually | boolean | _Available in Fleet Premium._ Specifies whether or not to require end users to manually turn on MDM in **Settings > Access work or school**. If `false`, MDM is automatically turned on for all Windows hosts that aren't connected to any MDM solution. | -| enable_disk_encryption | boolean | _Available in Fleet Premium._ Hosts that belong to no team will have disk encryption enabled if set to true. | -| windows_require_bitlocker_pin | boolean | _Available in Fleet Premium._ End users on Windows hosts that belong to no team will be required to set a BitLocker PIN if set to true. `enable_disk_encryption` must be set to true. When the PIN is set, it's required to unlock Windows host during startup. | +| enable_disk_encryption | boolean | _Available in Fleet Premium._ Hosts that are "Unassigned" will have disk encryption enabled if set to true. | +| windows_require_bitlocker_pin | boolean | _Available in Fleet Premium._ End users on Windows hosts that are "Unassigned" will be required to set a BitLocker PIN if set to true. `enable_disk_encryption` must be set to true. When the PIN is set, it's required to unlock Windows host during startup. | | macos_updates | object | See [`mdm.macos_updates`](#mdm-macos-updates). | | ios_updates | object | See [`mdm.ios_updates`](#mdm-ios-updates). | | ipados_updates | object | See [`mdm.ipados_updates`](#mdm-ipados-updates). | @@ -2434,8 +2448,8 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| minimum_version | string | Hosts that belong to no team and are enrolled into Fleet's MDM will be prompted to update when their OS is below this version. | -| deadline | string | Hosts that belong to no team and are enrolled into Fleet's MDM will be forced to update their OS after this deadline (noon local time for hosts already on macOS 14 or above, 20:00 UTC for hosts on earlier macOS versions). | +| minimum_version | string | Hosts that are "Unassigned" and have MDM turned on will be prompted to update when their OS is below this version. | +| deadline | string | Hosts that are "Unassigned" and have MDM turned on will be forced to update their OS after this deadline (7PM local time for hosts already on macOS 14 or above, 20:00 UTC for hosts on earlier macOS versions). | | update_new_hosts | string | macOS hosts that automatically enroll (ADE) are updated to [Apple's latest version](https://fleetdm.com/guides/enforce-os-updates) during macOS Setup Assistant. For backwards compatibility, if not specified, and `deadline` and `minimum_version` are set, `update_new_hosts` is set to `true`. Otherwise, `update_new_hosts` defaults to `false`. |
@@ -2448,8 +2462,8 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| minimum_version | string | Hosts that belong to no team will be prompted to update when their OS is below this version. | -| deadline | string | Hosts that belong to no team will be forced to update their OS after this deadline (noon local time). | +| minimum_version | string | Hosts that are "Unassigned" will be prompted to update when their OS is below this version. | +| deadline | string | Hosts that are "Unassigned" will be forced to update their OS after this deadline (7PM local time). |
@@ -2461,8 +2475,8 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| minimum_version | string | Hosts that belong to no team will be prompted to update when their OS is below this version. | -| deadline | string | Hosts that belong to no team will be forced to update their OS after this deadline (noon local time). | +| minimum_version | string | Hosts that are "Unassigned" will be prompted to update when their OS is below this version. | +| deadline | string | Hosts that are "Unassigned" will be forced to update their OS after this deadline (7PM local time). |
@@ -2474,8 +2488,8 @@ _Available in Fleet Premium._ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| deadline_days | integer | Hosts that belong to no team and are enrolled into Fleet's MDM will have this number of days before updates are installed on Windows. | -| grace_period_days | integer | Hosts that belong to no team and are enrolled into Fleet's MDM will have this number of days before Windows restarts to install updates. | +| deadline_days | integer | Hosts that are "Unassigned" and have MDM turned on will have this number of days before updates are installed on Windows. | +| grace_period_days | integer | Hosts that are "Unassigned" and have MDM turned on will have this number of days before Windows restarts to install updates. |
@@ -2722,11 +2736,11 @@ Delete all global enroll secrets. {} ``` -### Get team enroll secrets +### Get fleet enroll secrets -Returns the valid team enroll secrets. +Returns the fleet's enroll secrets. -`GET /api/v1/fleet/teams/:id/secrets` +`GET /api/v1/fleet/fleets/:id/secrets` #### Parameters @@ -2734,7 +2748,7 @@ None. #### Example -`GET /api/v1/fleet/teams/1/secrets` +`GET /api/v1/fleet/fleets/1/secrets` ##### Default response @@ -2746,31 +2760,32 @@ None. { "created_at": "2021-06-16T22:05:49Z", "secret": "aFtH2Nq09hrvi73ErlWNQfa7M53D3rPR", - "team_id": 1 + "team_id": 1, + "fleet_id": 1, } ] } ``` -### Update team enroll secrets +### Update fleet enroll secrets -Replaces all existing team enroll secrets. +Replaces all existing enroll secrets for a fleet. -`PATCH /api/v1/fleet/teams/:id/secrets` +`PATCH /api/v1/fleet/fleets/:id/secrets` #### Parameters | Name | Type | In | Description | | --------- | ------- | ---- | -------------------------------------- | -| id | integer | path | **Required**. The team's id. | +| id | integer | path | **Required**. The fleet's id. | | secrets | array | body | **Required**. A list of enroll secrets | #### Example -Replace all of a team's existing enroll secrets with a new enroll secret +Replace all of a fleet's existing enroll secrets with a new enroll secret -`PATCH /api/v1/fleet/teams/2/secrets` +`PATCH /api/v1/fleet/fleets/2/secrets` ##### Request body @@ -2801,9 +2816,9 @@ Replace all of a team's existing enroll secrets with a new enroll secret #### Example -Delete all of a team's existing enroll secrets +Delete all of a fleet's existing enroll secrets -`PATCH /api/v1/fleet/teams/2/secrets` +`PATCH /api/v1/fleet/fleets/2/secrets` ##### Request body @@ -2865,8 +2880,8 @@ None. - [Delete host](#delete-host) - [Refetch host](#refetch-host) - [Refetch host by Fleet Desktop token](#refetch-host-by-fleet-desktop-token) -- [Update hosts' team](#update-hosts-team) -- [Update hosts' team by filter](#update-hosts-team-by-filter) +- [Update hosts' fleet](#update-hosts-fleet) +- [Update hosts' fleet by filter](#update-hosts-fleet-by-filter) - [Turn off host's MDM](#turn-off-hosts-mdm) - [Batch-delete hosts](#batch-delete-hosts) - [Update human-device mapping](#update-human-device-mapping) @@ -2908,10 +2923,10 @@ the `software` table. - `created_at`: the time the row in the database was created, which usually corresponds to the first enrollment of the host. - `updated_at`: the last time the row in the database for the `hosts` table was updated. -- `detail_updated_at`: the last time Fleet updated host data, based on the results from the detail queries (this includes updates to host associated tables, e.g. `host_users`). -- `label_updated_at`: the last time Fleet updated the label membership for the host based on the results from the queries ran. +- `detail_updated_at`: the last time Fleet updated host data (this includes updates to host associated tables, e.g. `host_users`). +- `label_updated_at`: the last time Fleet updated the label membership for the host - `last_enrolled_at`: the last time the host enrolled to Fleet. -- `policy_updated_at`: the last time we updated the policy results for the host based on the queries ran. +- `policy_updated_at`: the last time we updated the policy results for the host - `seen_time`: the last time the host contacted the fleet server, regardless of what operation it was for. - `software_updated_at`: the last time software changed for the host in any way. - `last_restarted_at`: the last time that the host was restarted. @@ -2932,7 +2947,7 @@ the `software` table. | status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | | query | string | query | Search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an '@', no space, etc.). | | additional_info_filters | string | query | A comma-delimited list of fields to include in each host's `additional` object. This query is populated by the `additional_queries` in the `features` section of the configuration YAML. | -| team_id | integer | query | _Available in Fleet Premium_. Filters to only include hosts in the specified team. Use `0` to filter by hosts assigned to "No team". | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters to only include hosts in the specified fleet. Use `0` to filter by "Unassigned" hosts. | | policy_id | integer | query | The ID of the policy to filter hosts by. | | policy_response | string | query | **Requires `policy_id`**. Valid options are 'passing' or 'failing'. | | software_version_id | integer | query | The ID of the software version to filter hosts by. | @@ -2947,14 +2962,14 @@ the `software` table. | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Valid options are 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. 'pending' only includes Apple (macOS, iOS, iPadOS) hosts in Apple Business Manager (ABM) that are not yet enrolled to Fleet. | | connected_to_fleet | boolean | query | Filter hosts that are talking to this Fleet server for MDM features. In rare cases, hosts can be enrolled to one Fleet server but talk to a different Fleet server for MDM features. In this case, the value would be `false`. Always `false` for Linux hosts. | -| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | disable_failing_policies| boolean | query | If `true`, hosts will return failing policies as 0 regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | | macos_settings_disk_encryption | string | query | Filters the hosts by disk encryption status. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. | | bootstrap_package | string | query | _Available in Fleet Premium_. Filters the hosts by the status of the MDM bootstrap package on the host. Valid options are 'installed', 'pending', or 'failed'. | -| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | -| os_settings_disk_encryption | string | query | Filters the hosts by disk encryption status. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | +| os_settings_disk_encryption | string | query | Filters the hosts by disk encryption status. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | | populate_software | string | query | If `false` (or omitted), omits installed software details for each host. If `"without_vulnerability_details"`, include a list of installed software for each host, including which CVEs apply to the installed software versions. `true` adds vulnerability description, CVSS score, and other details when using Fleet Premium. See notes below on performance. | | populate_policies | boolean | query | If `true`, the response will include policy data for each host, including Fleet-maintained policies. | | populate_users | boolean | query | If `true`, the response will include user data for each host. | @@ -3048,7 +3063,9 @@ To filter hosts by platform (macOS, Windows, Linux), use the ["List label's host "status": "offline", "display_text": "Annas-MacBook-Pro.local", "team_id": null, + "fleet_id": null, "team_name": null, + "fleet_name": null, "gigs_disk_space_available": 174.98, "percent_disk_space_available": 71, "gigs_total_disk_space": 246, @@ -3236,7 +3253,7 @@ Response payload with the `munki_issue_id` filter provided: | after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. | | status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | | query | string | query | Search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an '@', no space, etc.). | -| team_id | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts in the specified team. | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts in the specified fleet. | | policy_id | integer | query | The ID of the policy to filter hosts by. | | policy_response | string | query | **Requires `policy_id`**. Valid options are 'passing' or 'failing'. | | software_version_id | integer | query | The ID of the software version to filter hosts by. | @@ -3245,17 +3262,17 @@ Response payload with the `munki_issue_id` filter provided: | os_name | string | query | The name of the operating system to filter hosts by. `os_version` must also be specified with `os_name` | | os_version | string | query | The version of the operating system to filter hosts by. `os_name` must also be specified with `os_version` | | vulnerability | string | query | The cve to filter hosts by (including "cve-" prefix, case-insensitive). | -| label_id | integer | query | A valid label ID. Can only be used in combination with `order_key`, `order_direction`, `after`, `status`, `query` and `team_id`. | +| label_id | integer | query | A valid label ID. Can only be used in combination with `order_key`, `order_direction`, `after`, `status`, `query` and `fleet_id`. | | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Valid options are 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. 'pending' only includes Apple (macOS, iOS, iPadOS) hosts in Apple Business Manager (ABM) that are not yet enrolled to Fleet. | -| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | macos_settings_disk_encryption | string | query | Filters the hosts by disk encryption status. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. | -| bootstrap_package | string | query | _Available in Fleet Premium_. Filters the hosts by the status of the MDM bootstrap package on the host. Valid options are 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | -| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | -| os_settings_disk_encryption | string | query | Filters the hosts by disk encryption status. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| bootstrap_package | string | query | _Available in Fleet Premium_. Filters the hosts by the status of the MDM bootstrap package on the host. Valid options are 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | +| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | +| os_settings_disk_encryption | string | query | Filters the hosts by disk encryption status. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | If `additional_info_filters` is not specified, no `additional` information will be returned. @@ -3295,13 +3312,13 @@ Returns the count of all hosts organized by status. `online_count` includes all | Name | Type | In | Description | | --------------- | ------- | ---- | ------------------------------------------------------------------------------- | -| team_id | integer | query | _Available in Fleet Premium_. The ID of the team whose host counts should be included. Defaults to all teams. | +| fleet_id | integer | query | _Available in Fleet Premium_. The ID of the fleet whose host counts should be included. Defaults to all fleets. | | platform | string | query | Platform to filter by when counting. Defaults to all platforms. | | low_disk_space | integer | query | _Available in Fleet Premium_. Returns the count of hosts with less GB of disk space available than this value. Must be a number between 1-100. | #### Example -`GET /api/v1/fleet/host_summary?team_id=1&low_disk_space=32` +`GET /api/v1/fleet/host_summary?fleet_id=1&low_disk_space=32` ##### Default response @@ -3310,6 +3327,7 @@ Returns the count of all hosts organized by status. `online_count` includes all ```json { "team_id": 1, + "fleet_id": 1, "totals_hosts_count": 2408, "online_count": 2267, "offline_count": 141, @@ -3493,8 +3511,10 @@ Returns the information of the specified host. "config_tls_refresh": 10, "logger_tls_period": 10, "team_id": null, + "fleet_id": null, "pack_stats": null, "team_name": null, + "fleet_name": null, "gigs_disk_space_available": 174.98, "percent_disk_space_available": 71, "gigs_total_disk_space": 246, @@ -3783,7 +3803,9 @@ If `hostname` is specified when there is more than one host with the same hostna "config_tls_refresh": 60, "logger_tls_period": 10, "team_id": 2, + "fleet_id": 2, "team_name": null, + "fleet_name": null, "gigs_disk_space_available": 19.29, "percent_disk_space_available": 74, "gigs_total_disk_space": 192, @@ -3858,7 +3880,9 @@ If `hostname` is specified when there is more than one host with the same hostna "hosts": null, "host_ids": null, "teams": null, - "team_ids": null + "fleet": null, + "team_ids": null, + "fleet_ids": null } ], "policies": [ @@ -3871,6 +3895,7 @@ If `hostname` is specified when there is more than one host with the same hostna "author_name": "", "author_email": "", "team_id": null, + "fleet_id": null, "resolution": "To enable full disk encryption, on the failing device, select System Preferences > Security & Privacy > FileVault > Turn On FileVault.", "platform": "darwin,linux", "created_at": "2022-09-02T18:52:19Z", @@ -4011,8 +4036,10 @@ X-Client-Cert-Serial: "config_tls_refresh": 10, "logger_tls_period": 10, "team_id": null, + "fleet_id": null, "pack_stats": null, "team_name": null, + "fleet_name": null, "additional": {}, "gigs_disk_space_available": 174.98, "percent_disk_space_available": 71, @@ -4166,7 +4193,7 @@ Deletes the specified host from Fleet. Note that a deleted host will fail authen ### Refetch host -Flags the host details, labels and policies to be refetched the next time the host checks in for distributed queries. Note that we cannot be certain when the host will actually check in and update the query results. Further requests to the host APIs will indicate that the refetch has been requested through the `refetch_requested` field on the host object. +Flags the host details, labels and policies to be refetched the next time the host checks in. Note that we cannot be certain when the host will actually check in. Further requests to the host APIs will indicate that the refetch has been requested through the `refetch_requested` field on the host object. `POST /api/v1/fleet/hosts/:id/refetch` @@ -4204,7 +4231,7 @@ Same as [Refetch host](#refetch-host) except with the Fleet Desktop token instea `Status: 200` -### Update hosts' team +### Update hosts' fleet _Available in Fleet Premium_ @@ -4214,7 +4241,7 @@ _Available in Fleet Premium_ | Name | Type | In | Description | | ------- | ------- | ---- | ----------------------------------------------------------------------- | -| team_id | integer | body | **Required**. The ID of the team you'd like to transfer the host(s) to. | +| fleet_id | integer | body | **Required**. The ID of the fleet you'd like to assign the host(s) to. | | hosts | array | body | **Required**. A list of host IDs. | #### Example @@ -4226,6 +4253,7 @@ _Available in Fleet Premium_ ```json { "team_id": 1, + "fleet_id": 1, "hosts": [3, 2, 4, 6, 1, 5, 7] } ``` @@ -4235,7 +4263,7 @@ _Available in Fleet Premium_ `Status: 200` -### Update hosts' team by filter +### Update hosts' fleet by filter _Available in Fleet Premium_ @@ -4245,7 +4273,7 @@ _Available in Fleet Premium_ | Name | Type | In | Description | | ------- | ------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| team_id | integer | body | **Required**. The ID of the team you'd like to transfer the host(s) to. | +| fleet_id | integer | body | **Required**. The ID of the fleet you'd like to assign the host(s) to. | | filters | object | body | **Required**. See [filters](#filters) | @@ -4256,7 +4284,7 @@ _Available in Fleet Premium_ | query | string | Search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, and `ipv4`. | | status | string | Host status. Can either be `new`, `online`, `offline`, `mia` or `missing`. | | label_id | number | ID of a label to filter by. | -| team_id | number | ID of the team to filter by. | +| fleet_id | number | ID of the fleet to filter by. | > Note: `label_id` and `status` filters cannot be used at the same time. @@ -4270,9 +4298,11 @@ _Available in Fleet Premium_ ```json { "team_id": 1, + "fleet_id": 1, "filters": { "status": "online", "team_id": 2, + "fleet_id": 2, } } ``` @@ -4324,7 +4354,7 @@ Delete hosts selected by filter or ids. | query | string | Search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, and `ipv4`. | | status | string | Host status. Can either be `new`, `online`, `offline`, `mia` or `missing`. | | label_id | number | ID of a label to filter by. | -| team_id | number | ID of the team to filter by. | +| fleet_id | number | ID of the fleet to filter by. | > Notes: `label_id` and `status` filters cannot be used at the same time. @@ -4344,6 +4374,7 @@ Request (using `filters`): "status": "online", "label_id": 1, "team_id": 1, + "fleet_id": 1, "query": "abc" } } @@ -4366,7 +4397,8 @@ Request (`filters` is specified and empty, to delete all hosts): { "filters": { "status": "online", - "team_id": 1 + "team_id": 1, + "fleet_id": 1 } } ``` @@ -4528,14 +4560,14 @@ Retrieves MDM enrollment summary. Windows servers are excluded from the aggregat | Name | Type | In | Description | | -------- | ------- | ----- | -------------------------------------------------------------------------------- | -| team_id | integer | query | _Available in Fleet Premium_. Filter by team | +| fleet_id | integer | query | _Available in Fleet Premium_. Filter by fleet. | | platform | string | query | Filter by platform ("windows" or "darwin") | -A `team_id` of `0` returns the statistics for hosts that are not part of any team. A `null` or missing `team_id` returns statistics for all hosts regardless of the team. +A `fleet_id` of `0` returns the statistics for hosts that are "Unassigned". A `null` or missing `fleet_id` returns statistics for all hosts on all fleets. #### Example -`GET /api/v1/fleet/hosts/summary/mdm?team_id=1&platform=windows` +`GET /api/v1/fleet/hosts/summary/mdm?fleet_id=1&platform=windows` ##### Default response @@ -4634,9 +4666,9 @@ Retrieves MDM enrollment status and Munki versions, aggregated across all hosts. | Name | Type | In | Description | | ------- | ------- | ----- | ---------------------------------------------------------------------------------------------------------------- | -| team_id | integer | query | _Available in Fleet Premium_. Filters the aggregate host information to only include hosts in the specified team. | | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters the aggregate host information to only include hosts in the specified fleet. | | -A `team_id` of `0` returns the statistics for hosts that are not part of any team. A `null` or missing `team_id` returns statistics for all hosts regardless of the team. +A `fleet_id` of `0` returns the statistics for hosts that are "Unassigned". A `null` or missing `fleet_id` returns statistics for all hosts on all fleets. #### Example @@ -4808,7 +4840,7 @@ Currently, `hash_sha256`, `executable_sha256`, and `executable_path` are only su { "id": 147, "name": "Logic Pro", - "icon_url": "/api/latest/fleet/software/titles/147/icon?team_id=2", + "icon_url": "/api/latest/fleet/software/titles/147/icon?fleet_id=2", "software_package": null, "app_store_app": { "app_store_id": "1091189122", @@ -4905,7 +4937,7 @@ requested by a web browser. | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `"asc"` and `"desc"`. Default is `"asc"`. | | status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | | query | string | query | Search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | -| team_id | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts in the specified team. | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts in the specified fleet. | | policy_id | integer | query | The ID of the policy to filter hosts by. | | policy_response | string | query | **Requires `policy_id`**. Valid options are 'passing' or 'failing'. **Note: If `policy_id` is specified _without_ including `policy_response`, this will also return hosts where the policy is not configured to run or failed to run.** | | software_version_id | integer | query | The ID of the software version to filter hosts by. | @@ -4917,11 +4949,11 @@ requested by a web browser. | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Valid options are 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. 'pending' only includes Apple (macOS, iOS, iPadOS) hosts in Apple Business Manager (ABM) that are not yet enrolled to Fleet. | -| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only hosts that are "Unassigned".** | | munki_issue_id | integer | query | The ID of the _munki issue_ (a Munki-reported error or warning message) to filter hosts by (that is, filter hosts that are affected by that corresponding error or warning message). | | low_disk_space | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | -| label_id | integer | query | A valid label ID. Can only be used in combination with `order_key`, `order_direction`, `status`, `query` and `team_id`. | -| bootstrap_package | string | query | _Available in Fleet Premium_. Filters the hosts by the status of the MDM bootstrap package on the host. Valid options are 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| label_id | integer | query | A valid label ID. Can only be used in combination with `order_key`, `order_direction`, `status`, `query` and `fleet_id`. | +| bootstrap_package | string | query | _Available in Fleet Premium_. Filters the hosts by the status of the MDM bootstrap package on the host. Valid options are 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only hosts that are "Unassigned".** | | disable_failing_policies | boolean | query | If `true`, hosts will return failing policies as 0 (returned as the `issues` column) regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | If `mdm_id`, `mdm_name` or `mdm_enrollment_status` is specified, then Windows Servers are excluded from the results. @@ -4935,7 +4967,7 @@ If `mdm_id`, `mdm_name` or `mdm_enrollment_status` is specified, then Windows Se `Status: 200` ```csv -created_at,updated_at,id,detail_updated_at,label_updated_at,policy_updated_at,last_enrolled_at,seen_time,refetch_requested,hostname,uuid,platform,osquery_version,os_version,build,platform_like,code_name,uptime,memory,cpu_type,cpu_subtype,cpu_brand,cpu_physical_cores,cpu_logical_cores,hardware_vendor,hardware_model,hardware_version,hardware_serial,computer_name,primary_ip_id,primary_ip,primary_mac,distributed_interval,config_tls_refresh,logger_tls_period,team_id,team_name,gigs_disk_space_available,percent_disk_space_available,gigs_total_disk_space,issues,device_mapping,status,display_text +created_at,updated_at,id,detail_updated_at,label_updated_at,policy_updated_at,last_enrolled_at,seen_time,refetch_requested,hostname,uuid,platform,osquery_version,os_version,build,platform_like,code_name,uptime,memory,cpu_type,cpu_subtype,cpu_brand,cpu_physical_cores,cpu_logical_cores,hardware_vendor,hardware_model,hardware_version,hardware_serial,computer_name,primary_ip_id,primary_ip,primary_mac,distributed_interval,config_tls_refresh,logger_tls_period,team_name,fleet_name,gigs_disk_space_available,percent_disk_space_available,gigs_total_disk_space,issues,device_mapping,status,display_text 2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,1,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,false,foo.local0,a4fc55a1-b5de-409c-a2f4-441f564680d3,debian,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0,0,0,,,, 2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:22:56Z,false,foo.local1,689539e5-72f0-4bf7-9cc5-1530d3814660,rhel,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0,0,0,,,, 2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,3,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:21:56Z,false,foo.local2,48ebe4b0-39c3-4a74-a67f-308f7b5dd171,linux,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0,0,0,,,, @@ -5067,6 +5099,7 @@ Retrieves a list of the configuration profiles assigned to a host. { "profile_uuid": "bc84dae7-396c-4e10-9d45-5768bce8b8bd", "team_id": 0, + "fleet_id": 0, "name": "Example profile", "identifier": "com.example.profile", "created_at": "2023-03-31T00:00:00Z", @@ -5620,14 +5653,15 @@ The `hostname` host identifier is deprecated. Please use `host_ids`, `hardware_s "count": 0, "host_ids": null, "author_id": 1, - "team_id": null + "team_id": null, + "fleet_id": null } } ``` ### Update label -Updates the specified label. Note: Label queries, platforms, and teams are immutable. To change these, you must delete the label and create a new label. +Updates the specified label. Note: Label queries, platforms, and fleets are immutable. To change these, you must delete the label and create a new label. `PATCH /api/v1/fleet/labels/:id` @@ -5676,7 +5710,9 @@ The `hostname` host identifier is deprecated. Please use `host_ids`, `hardware_s "host_ids": [42, 43], "author_id": 1, "team_id": null, - "team_name": null + "fleet_id": null, + "team_name": null, + "fleet_name": null } } ``` @@ -5717,7 +5753,9 @@ Returns the specified label. "host_ids": null, "author_id": 1, "team_id": null, - "team_name": null + "fleet_id": null, + "team_name": null, + "fleet_name": null, } } ``` @@ -5732,7 +5770,7 @@ Returns a list of labels in Fleet, including basic information on each label. | Name | Type | In | Description | | --------------- | ------- | ----- |------------------------------------- | -| team_id | string | query | _Available in Fleet Premium._ Filters to labels belonging to the specified team, plus global labels. Specify `"global"` to show only globally-available labels. If omitted, Fleet returns all global labels, plus all labels for teams to which the requestor has access. | +| fleet_id | string | query | _Available in Fleet Premium._ Filters to labels belonging to the specified fleet, plus global labels. Specify `"global"` to show only globally-available labels. If omitted, Fleet returns all global labels, plus all labels for fleets to which the requestor has access. | #### Example @@ -5750,42 +5788,48 @@ Returns a list of labels in Fleet, including basic information on each label. "name": "All Hosts", "description": "All hosts which have enrolled in Fleet", "label_type": "builtin", - "team_id": null + "team_id": null, + "fleet_id": null }, { "id": 7, "name": "macOS", "description": "All macOS hosts", "label_type": "builtin", - "team_id": null + "team_id": null, + "fleet_id": null }, { "id": 8, "name": "Ubuntu Linux", "description": "All Ubuntu hosts", "label_type": "builtin", - "team_id": null + "team_id": null, + "fleet_id": null }, { "id": 9, "name": "CentOS Linux", "description": "All CentOS hosts", "label_type": "builtin", - "team_id": null + "team_id": null, + "fleet_id": null }, { "id": 10, "name": "MS Windows", "description": "All Windows hosts", "label_type": "builtin", - "team_id": null + "team_id": null, + "fleet_id": null }, { "id": 11, - "name": "My team-specific label", - "description": "This one goes to eleven, but only on one team", + "name": "My fleet-specific label", + "description": "This one goes to eleven, but only on one fleet", "label_type": "regular", - "team_id": 1 + "team_id": 1, + "fleet_id": 1 } ] } @@ -5804,7 +5848,7 @@ Returns a list of labels. | include_host_counts | boolean | query | Whether or not to calculate host counts for each label. Default is `true`. See "additional notes" for more information. | | order_key | string | query | What to order results by. Can be any column in the labels table. | | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `"asc"` and `"desc"`. Default is `"asc"`. | -| team_id | string | query | _Available in Fleet Premium._ Filters to labels belonging to the specified team, plus global labels. Specify `"global"` to show only globally-available labels. If omitted, Fleet returns all global labels, plus all labels for teams to which the requestor has access. | +| fleet_id | string | query | _Available in Fleet Premium._ Filters to labels belonging to the specified fleet, plus global labels. Specify `"global"` to show only globally-available labels. If omitted, Fleet returns all global labels, plus all labels for fleets to which the requestor has access. | When `include_host_counts` is `true` (or omitted), `host_count` will only be included for `labels` that are in use by one or more hosts, but `count` will always be included, even if it is `0`. When `include_host_counts` is `false`, `host_count` will always be omitted, and `count` will be returned as `0` for each label. Setting `include_host_counts=false` will improve API performance, especially on deployments with large numbers of hosts and labels. @@ -5833,7 +5877,8 @@ When `include_host_counts` is `true` (or omitted), `host_count` will only be inc "count": 7, "host_ids": null, "author_id": 1, - "team_id": null + "team_id": null, + "fleet_id": null }, { "created_at": "2021-02-02T23:55:25Z", @@ -5850,7 +5895,8 @@ When `include_host_counts` is `true` (or omitted), `host_count` will only be inc "count": 1, "host_ids": null, "author_id": 1, - "team_id": null + "team_id": null, + "fleet_id": null }, { "created_at": "2021-02-02T23:55:25Z", @@ -5867,7 +5913,8 @@ When `include_host_counts` is `true` (or omitted), `host_count` will only be inc "count": 3, "host_ids": null, "author_id": 1, - "team_id": null + "team_id": null, + "fleet_id": null }, { "created_at": "2021-02-02T23:55:25Z", @@ -5883,7 +5930,8 @@ When `include_host_counts` is `true` (or omitted), `host_count` will only be inc "count": 3, "host_ids": null, "author_id": 1, - "team_id": null + "team_id": null, + "fleet_id": null }, { "created_at": "2021-02-02T23:55:25Z", @@ -5899,14 +5947,15 @@ When `include_host_counts` is `true` (or omitted), `host_count` will only be inc "count": 0, "host_ids": null, "author_id": 1, - "team_id": null + "team_id": null, + "fleet_id": null }, { "created_at": "2025-11-13T06:14:20Z", "updated_at": "2025-11-13T06:14:20Z", "id": 4663, "name": "Team: g-software", - "description": "Workstations used by team g-software", + "description": "Workstations used by g-software", "query": "", "platform": "", "label_type": "regular", @@ -5915,7 +5964,8 @@ When `include_host_counts` is `true` (or omitted), `host_count` will only be inc "count": 0, "host_ids": null, "author_id": 1, - "team_id": 2 + "team_id": 2, + "fleet_id": null } ] } @@ -5939,17 +5989,17 @@ Returns a list of the hosts that belong to the specified label. | after | string | query | The value to get results after. This needs `order_key` defined, as that's the column that would be used. | | status | string | query | Indicates the status of the hosts to return. Can either be 'new', 'online', 'offline', 'mia' or 'missing'. | | query | string | query | Search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, and `ipv4`. | -| team_id | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts in the specified team. | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts in the specified fleet. | | disable_failing_policies | boolean | query | If "true", hosts will return failing policies as 0 regardless of whether there are any that failed for the host. This is meant to be used when increased performance is needed in exchange for the extra information. | | mdm_id | integer | query | The ID of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider and URL). | | mdm_name | string | query | The name of the _mobile device management_ (MDM) solution to filter hosts by (that is, filter hosts that use a specific MDM provider). | | mdm_enrollment_status | string | query | The _mobile device management_ (MDM) enrollment status to filter hosts by. Valid options are 'manual', 'automatic', 'enrolled', 'pending', or 'unenrolled'. 'pending' only includes Apple (macOS, iOS, iPadOS) hosts in Apple Business Manager (ABM) that are not yet enrolled to Fleet. | -| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| macos_settings | string | query | Filters the hosts by the status of the _mobile device management_ (MDM) profiles applied to hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | | low_disk_space | integer | query | _Available in Fleet Premium_. Filters the hosts to only include hosts with less GB of disk space available than this value. Must be a number between 1-100. | | macos_settings_disk_encryption | string | query | Filters the hosts by disk encryption status. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. | -| bootstrap_package | string | query | _Available in Fleet Premium_. Filters the hosts by the status of the MDM bootstrap package on the host. Valid options are 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | -| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | -| os_settings_disk_encryption | string | query | Filters the hosts by disk encryption status. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a team ID filter, the results include only hosts that are not assigned to any team.** | +| bootstrap_package | string | query | _Available in Fleet Premium_. Filters the hosts by the status of the MDM bootstrap package on the host. Valid options are 'installed', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | +| os_settings | string | query | Filters the hosts by the status of the operating system settings applied to the hosts. Valid options are 'verified', 'verifying', 'pending', or 'failed'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | +| os_settings_disk_encryption | string | query | Filters the hosts by disk encryption status. Valid options are 'verified', 'verifying', 'action_required', 'enforcing', 'failed', or 'removing_enforcement'. **Note: If this filter is used in Fleet Premium without a fleet ID filter, the results include only "Unassigned" hosts.** | If `mdm_id`, `mdm_name`, `mdm_enrollment_status`, `os_settings`, or `os_settings_disk_encryption` is specified, then Windows Servers are excluded from the results. @@ -6003,8 +6053,10 @@ If `mdm_id`, `mdm_name`, `mdm_enrollment_status`, `os_settings`, or `os_settings "config_tls_refresh": 10, "logger_tls_period": 10, "team_id": null, + "fleet_id": null, "pack_stats": null, "team_name": null, + "fleet_name": null, "status": "offline", "display_text": "e2e7f8d8983d", "mdm": { @@ -6096,7 +6148,7 @@ Add a configuration profile to enforce custom settings on macOS and Windows host | Name | Type | In | Description | | ------------------------- | -------- | ---- | ------------------------------------------------------------------------------------------------------------- | | profile | file | body | **Required.** The .mobileconfig and JSON for macOS or XML for Windows file containing the profile. | -| team_id | string | body | _Available in Fleet Premium_. The team ID for the profile. If specified, the profile is applied to only hosts that are assigned to the specified team. If not specified, the profile is applied to only to hosts that are not assigned to any team. | +| fleet_id | string | body | _Available in Fleet Premium_. The fleet ID for the profile. If specified, the profile is applied to only hosts that are assigned to the specified fleet. If not specified, the profile is applied to only hosts that are "Unassigned". | | labels_include_all | array | body | _Available in Fleet Premium_. Target hosts that have all labels, specified by label name, in the array. | | labels_include_any | array | body | _Available in Fleet Premium_. Target hosts that have any label, specified by label name, in the array. | | labels_exclude_any | array | body | _Available in Fleet Premium_. Target hosts that that don’t have any label, specified by label name, in the array. | @@ -6109,7 +6161,7 @@ of duplicate payload display name or duplicate payload identifier (macOS profile #### Example Add a new configuration profile to be applied to macOS hosts -assigned to a team. Note that in this example the form data specifies`team_id` in addition to +assigned to a fleet. Note that in this example the form data specifies `fleet_id` in addition to `profile`. `POST /api/v1/fleet/configuration_profiles` @@ -6118,7 +6170,7 @@ assigned to a team. Note that in this example the form data specifies`team_id` i ```http profile="Foo.mobileconfig" -team_id="1" +fleet_id="1" labels_include_all="Label name 1" ``` @@ -6139,8 +6191,8 @@ labels_include_all="Label name 1" Get a list of the configuration profiles in Fleet. For Fleet Premium, the list can -optionally be filtered by team ID. If no team ID is specified, team profiles are excluded from the -results (i.e., only profiles that are associated with "No team" are listed). +optionally be filtered by fleet ID. If no fleet ID is specified, fleet profiles are excluded from the +results (i.e., only profiles that are associated with "Unassigned" are listed). `GET /api/v1/fleet/configuration_profiles` @@ -6148,13 +6200,13 @@ results (i.e., only profiles that are associated with "No team" are listed). | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | string | query | _Available in Fleet Premium_. The team id to filter profiles. | +| fleet_id | string | query | _Available in Fleet Premium_. The fleet id to filter profiles. | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | #### Example -List all configuration profiles for macOS and Windows hosts enrolled to Fleet's MDM that are not assigned to any team. +List all configuration profiles for macOS and Windows hosts enrolled to Fleet's MDM that are "Unassigned". `GET /api/v1/fleet/configuration_profiles` @@ -6341,7 +6393,7 @@ Resends a configuration profile for the specified host. Currently, macOS, iOS, i ### Batch-update custom OS settings (configuration profiles) -Modify configuration profiles for a team. The provided list of profiles will be the active profiles for the specified team. If no team (`team_id` or `team_name`) is provided, the profiles are applied for all hosts (Fleet Free) or for hosts that are assigned to "No team" (Fleet Premium). +Modify configuration profiles for a fleet. The provided list of profiles will be the active profiles for the specified fleet. If no fleet (`fleet_id` or `fleet_name`) is provided, the profiles are applied for all hosts (Fleet Free) or for hosts that are "Unassigned" (Fleet Premium). For Apple (macOS, iOS, iPadOS) profiles, Fleet will send only an `InstallProfile` command (edit) for all existing profiles with the same `PayloadIdentifier` (specified in the .mobileconfig file). Fleet will send a `RemoveProfile` command to hosts for all existing profiles that are not part of the list. @@ -6359,8 +6411,8 @@ For requests with 100+ profiles, requests will take 5+ seconds. | Name | Type | In | Description | | --------- | ------ | ----- | --------------------------------------------------------------------------------------------------------------------------------- | -| team_id | number | query | _Available in Fleet Premium_ The team ID to apply the configuration profiles to. Only one of `team_name` or `team_id` may be included in the request. | -| team_name | string | query | _Available in Fleet Premium_ The name of the team to apply the custom settings to. Only one of `team_name` or `team_id` may be included in the request. | +| fleet_id | number | query | _Available in Fleet Premium_ The fleet ID to apply the configuration profiles to. Only one of `fleet_name` or `fleet_id` may be included in the request. | +| fleet_name | string | query | _Available in Fleet Premium_ The name of the fleet to apply the custom settings to. Only one of `fleet_name` or `fleet_id` may be included in the request. | | dry_run | bool | query | Validate the provided profiles and return any validation errors, but do not apply the changes. | | configuration_profiles | object | body | **Required**. See [configuration_profiles](#configuration-profiles) | @@ -6378,7 +6430,7 @@ For each `profile`, only one of `labels_include_all`, `labels_include_any`, or ` #### Example -`POST /api/v1/fleet/configuration_profiles/batch?team_id=1` +`POST /api/v1/fleet/configuration_profiles/batch?fleet_id=1` ##### Request body @@ -6471,8 +6523,8 @@ _Available in Fleet Premium_ | Name | Type | In | Description | | ------------- | ------ | ---- | -------------------------------------------------------------------------------------- | -| team_id | integer | body | The team ID to apply the settings to. Settings applied to hosts in no team if absent. | -| enable_disk_encryption | boolean | body | Whether disk encryption should be enforced on devices that belong to the team (or no team). | +| fleet_id | integer | body | The fleet ID to apply the settings to. Settings are applied to "Unassigned" hosts if absent. | +| enable_disk_encryption | boolean | body | Whether disk encryption should be enforced on devices that belong to the fleet (or "Unassigned"). | | windows_require_bitlocker_pin | boolean | body | End users on Windows hosts will be required to set a BitLocker PIN if set to true. `enable_disk_encryption` must be set to true. When the PIN is set, it's required to unlock Windows host during startup. | #### Example @@ -6490,7 +6542,7 @@ _Available in Fleet Premium_ Get aggregate status counts of disk encryption enforced on macOS and Windows hosts. -The summary can optionally be filtered by team ID. +The summary can optionally be filtered by fleet ID. `GET /api/v1/fleet/disk_encryption` @@ -6498,7 +6550,7 @@ The summary can optionally be filtered by team ID. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | string | query | _Available in Fleet Premium_. The team ID to filter the summary. | +| fleet_id | string | query | _Available in Fleet Premium_. The fleet ID to filter the summary. | #### Example @@ -6527,7 +6579,7 @@ The summary can optionally be filtered by team ID. Get aggregate status counts of all OS settings (configuration profiles and disk encryption) enforced on hosts. For Fleet Premium users, the counts can -optionally be filtered by `team_id`. If no `team_id` is specified, team profiles are excluded from the results (i.e., only profiles that are associated with "No team" are listed). +optionally be filtered by `fleet_id`. If no `fleet_id` is specified, fleet profiles are excluded from the results (i.e., only profiles that are associated with "Unassigned" are listed). `GET /api/v1/fleet/configuration_profiles/summary` @@ -6535,11 +6587,11 @@ optionally be filtered by `team_id`. If no `team_id` is specified, team profiles | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | string | query | _Available in Fleet Premium_. The team ID to filter profiles. | +| fleet_id | string | query | _Available in Fleet Premium_. The fleet ID to filter profiles. | #### Example -Get aggregate status counts of profiles for to macOS and Windows hosts that are assigned to "No team". +Get aggregate status counts of profiles for macOS and Windows hosts that are "Unassigned". `GET /api/v1/fleet/configuration_profiles/summary` @@ -6616,7 +6668,7 @@ Get status counts of a single OS settings (configuration profile) enforced on ho _Available in Fleet Premium_ -Sets the custom MDM setup enrollment profile for a team or no team. +Sets the custom MDM setup enrollment profile for a fleet or "Unassigned". `POST /api/v1/fleet/enrollment_profiles/automatic` @@ -6624,7 +6676,7 @@ Sets the custom MDM setup enrollment profile for a team or no team. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | integer | json | The team ID this custom enrollment profile applies to, or no team if omitted. | +| fleet_id | integer | json | The fleet ID this custom enrollment profile applies to, or "Unassigned" if omitted. | | name | string | json | The filename of the uploaded custom enrollment profile. | | enrollment_profile | object | json | The custom enrollment profile's json, as documented in https://developer.apple.com/documentation/devicemanagement/profile. | @@ -6639,6 +6691,7 @@ Sets the custom MDM setup enrollment profile for a team or no team. ```json { "team_id": 123, + "fleet_id": 123, "name": "dep_profile.json", "uploaded_at": "2023-04-04:00:00Z", "enrollment_profile": { @@ -6654,7 +6707,7 @@ Sets the custom MDM setup enrollment profile for a team or no team. _Available in Fleet Premium_ -Gets the custom MDM setup enrollment profile for a team or no team. +Gets the custom MDM setup enrollment profile for a fleet or "Unassigned". `GET /api/v1/fleet/enrollment_profiles/automatic` @@ -6662,11 +6715,11 @@ Gets the custom MDM setup enrollment profile for a team or no team. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | integer | query | The team ID for which to return the custom enrollment profile, or no team if omitted. | +| fleet_id | integer | query | The fleet ID for which to return the custom enrollment profile, or "Unassigned" if omitted. | #### Example -`GET /api/v1/fleet/enrollment_profiles/automatic?team_id=123` +`GET /api/v1/fleet/enrollment_profiles/automatic?fleet_id=123` ##### Default response @@ -6675,6 +6728,7 @@ Gets the custom MDM setup enrollment profile for a team or no team. ```json { "team_id": 123, + "fleet_id": 123, "name": "dep_profile.json", "uploaded_at": "2023-04-04:00:00Z", "enrollment_profile": { @@ -6688,7 +6742,7 @@ Gets the custom MDM setup enrollment profile for a team or no team. _Available in Fleet Premium_ -Deletes the custom MDM setup enrollment profile assigned to a team or no team. +Deletes the custom MDM setup enrollment profile assigned to a fleet or "Unassigned". `DELETE /api/v1/fleet/enrollment_profiles/automatic` @@ -6696,11 +6750,11 @@ Deletes the custom MDM setup enrollment profile assigned to a team or no team. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | integer | query | The team ID for which to delete the custom enrollment profile, or no team if omitted. | +| fleet_id | integer | query | The fleet ID for which to delete the custom enrollment profile, or "Unassigned" if omitted. | #### Example -`DELETE /api/v1/fleet/enrollment_profiles/automatic?team_id=123` +`DELETE /api/v1/fleet/enrollment_profiles/automatic?fleet_id=123` ##### Default response @@ -6711,9 +6765,9 @@ Deletes the custom MDM setup enrollment profile assigned to a team or no team. `GET /api/v1/fleet/enrollment_profiles/ota` -The returned value is a signed `.mobileconfig` OTA enrollment profile (see [Apple enrollment profile docs](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/OTASecurity/OTASecurity.html)). Install this profile on macOS, iOS, or iPadOS hosts to enroll them to a specific team in Fleet and turn on MDM features. +The returned value is a signed `.mobileconfig` OTA enrollment profile (see [Apple enrollment profile docs](https://developer.apple.com/library/archive/documentation/NetworkingInternet/Conceptual/iPhoneOTAConfiguration/OTASecurity/OTASecurity.html)). Install this profile on macOS, iOS, or iPadOS hosts to enroll them to a specific fleet in Fleet and turn on MDM features. -If the team in Fleet has [end user authentication](https://fleetdm.com/guides/setup-experience#end-user-authentication) enabled, the OTA enrollment profile won't work. Use the [manual enrollment profile](#get-manual-enrollment-profile) instead. +If the fleet has [end user authentication](https://fleetdm.com/guides/setup-experience#end-user-authentication) enabled, the OTA enrollment profile won't work. Use the [manual enrollment profile](#get-manual-enrollment-profile) instead. To enroll macOS hosts, turn on MDM features, and add [human-device mapping](https://fleetdm.com/guides/foreign-vitals-map-idp-users-to-hosts), use the [manual enrollment profile](#get-manual-enrollment-profile) instead. @@ -6721,7 +6775,7 @@ To enroll macOS hosts, turn on MDM features, and add [human-device mapping](http | Name | Type | In | Description | |-------------------|---------|-------|----------------------------------------------------------------------------------| -| enroll_secret | string | query | **Required**. The enroll secret of the team this host will be assigned to. | +| enroll_secret | string | query | **Required**. The enroll secret of the fleet this host will be assigned to. | #### Example @@ -6819,13 +6873,13 @@ Upload a bootstrap package that will be automatically installed during DEP setup | Name | Type | In | Description | | ------- | ------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | package | file | body | **Required**. The bootstrap package installer. It must be a signed `pkg` file. | -| team_id | string | body | The team ID for the package. If specified, the package will be installed to hosts that are assigned to the specified team. If not specified, the package will be installed to hosts that are not assigned to any team. | +| fleet_id | string | body | The fleet ID for the package. If specified, the package will be installed to hosts that are assigned to the specified fleet. If not specified, the package will be installed on "Unassigned" hosts. | | manual_agent_install | boolean | body | If set to `true` Fleet's agent (fleetd) won't be installed as part of automatic enrollment (ADE) on macOS hosts. (Default: `false`) | #### Example Upload a bootstrap package that will be installed to macOS hosts enrolled to MDM that are -assigned to a team. Note that in this example the form data specifies `team_id` in addition to +assigned to a fleet. Note that in this example the form data specifies `fleet_id` in addition to `package`. `POST /api/v1/fleet/bootstrap` @@ -6833,7 +6887,7 @@ assigned to a team. Note that in this example the form data specifies `team_id` ##### Request body ```http -team_id="1" +fleet_id="1" package="bootstrap-package.pkg" ``` @@ -6847,13 +6901,13 @@ _Available in Fleet Premium_ Get information about a bootstrap package that was uploaded to Fleet. -`GET /api/v1/fleet/bootstrap/:team_id/metadata` +`GET /api/v1/fleet/bootstrap/:fleet_id/metadata` #### Parameters | Name | Type | In | Description | | ------- | ------ | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| team_id | string | url | **Required** The team ID for the package. Zero (0) can be specified to get information about the bootstrap package for hosts that don't belong to a team. | +| fleet_id | string | url | **Required** The fleet ID for the package. Zero (0) can be specified to get information about the bootstrap package for "Unassigned" hosts. | | for_update | boolean | query | If set to `true`, the authorization will be for a `write` action instead of a `read`. Useful for the write-only `gitops` role when requesting the bootstrap metadata to check if the package needs to be replaced. | #### Example @@ -6868,6 +6922,7 @@ Get information about a bootstrap package that was uploaded to Fleet. { "name": "bootstrap-package.pkg", "team_id": 0, + "fleet_id": 0, "sha256": "6bebb4433322fd52837de9e4787de534b4089ac645b0692dfb74d000438da4a3", "token": "AA598E2A-7952-46E3-B89D-526D45F7E233", "created_at": "2023-04-20T13:02:05Z" @@ -6884,15 +6939,15 @@ In the response above: _Available in Fleet Premium_ -Delete a team's bootstrap package. +Delete a fleet's bootstrap package. -`DELETE /api/v1/fleet/bootstrap/:team_id` +`DELETE /api/v1/fleet/bootstrap/:fleet_id` #### Parameters | Name | Type | In | Description | | ------- | ------ | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| team_id | string | url | **Required** The team ID for the package. Zero (0) can be specified to get information about the bootstrap package for hosts that don't belong to a team. | +| fleet_id | string | url | **Required** The fleet ID for the package. Zero (0) can be specified to get information about the bootstrap package for "Unassigned" hosts. | #### Example @@ -6940,7 +6995,7 @@ _Available in Fleet Premium_ Get aggregate status counts of bootstrap packages delivered to DEP enrolled hosts. -The summary can optionally be filtered by team ID. +The summary can optionally be filtered by fleet ID. `GET /api/v1/fleet/bootstrap/summary` @@ -6948,7 +7003,7 @@ The summary can optionally be filtered by team ID. | Name | Type | In | Description | | ------------------------- | ------ | ----- | ------------------------------------------------------------------------- | -| team_id | string | query | The team ID to filter the summary. | +| fleet_id | string | query | The fleet ID to filter the summary. | #### Example @@ -6978,7 +7033,7 @@ _Available in Fleet Premium_ | Name | Type | In | Description | | ------------- | ------ | ---- | -------------------------------------------------------------------------------------- | -| team_id | integer | body | The team ID to apply the settings to. Settings applied to hosts in no team if absent. | +| fleet_id | integer | body | The fleet ID to apply the settings to. Settings are applied to "Unassigned" hosts if absent. | | enable_end_user_authentication | boolean | body | When enabled, require end users to authenticate with your identity provider (IdP) when they set up their new macOS hosts. | | require_all_software_macos | boolean | body | If set to `true`, setup will be canceled on macOS hosts if any software installs fail. | | enable_release_device_manually | boolean | body | When enabled, you're responsible for sending the [`DeviceConfigured` command](https://developer.apple.com/documentation/devicemanagement/device-configured-command). End users will be stuck in Setup Assistant until this command is sent. | @@ -6993,6 +7048,7 @@ _Available in Fleet Premium_ ```json { "team_id": 1, + "fleet_id": 1, "enable_end_user_authentication": true, "enable_release_device_manually": true } @@ -7131,14 +7187,14 @@ List software that can be automatically installed during setup. If `install_duri | Name | Type | In | Description | | ----- | ------ | ----- | ---------------------------------------- | | platform | string | query | Filters software titles available for install by platforms. Options are `"macos"`, `"windows"`, `"linux"`, `"ios"`, `"ipados"`, and `"android"`. Defaults to `"macos"`. To show titles from multiple platforms, separate the platforms with commas (e.g. `?platform=macos,ios,android`). | -| team_id | integer | query | _Available in Fleet Premium_. The ID of the team to filter software by. If not specified, it will filter only software that's available to hosts with no team. | +| fleet_id | integer | query | _Available in Fleet Premium_. The ID of the fleet to filter software by. If not specified, it will filter only software that's available for "Unassigned" hosts. | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | #### Example -`GET /api/v1/fleet/setup_experience/software?team_id=3` +`GET /api/v1/fleet/setup_experience/software?fleet_id=3` ##### Default response @@ -7150,7 +7206,7 @@ List software that can be automatically installed during setup. If `install_duri { "id": 12, "name": "Firefox.app", - "icon_url": "/api/latest/fleet/software/titles/12/icon?team_id=3", + "icon_url": "/api/latest/fleet/software/titles/12/icon?fleet_id=3", "software_package": { "name": "FirefoxInstall.pkg", "platform": "darwin", @@ -7204,7 +7260,7 @@ Set software that will be automatically installed during setup. Software that is | Name | Type | In | Description | | ----- | ------ | ----- | ---------------------------------------- | | platform | string | query | Platform to install software for. Either `"macos"`, `"windows"`, `"linux"`, `"ios"`, `"ipados"`, or `"android"`. Defaults to `"macos"`. | -| team_id | integer | query | _Available in Fleet Premium_. The ID of the team to set the software for. If not specified, it will set the software for hosts with no team. | +| fleet_id | integer | query | _Available in Fleet Premium_. The ID of the fleet to set the software for. If not specified, it will set the software for "Unassigned" hosts. | | software_title_ids | array | body | The ID of software titles to install during setup. | #### Example @@ -7217,6 +7273,7 @@ Set software that will be automatically installed during setup. Software that is { "platform": "linux", "team_id": 1, + "fleet_id": 1, "software_title_ids": [3000, 3001] } ``` @@ -7241,7 +7298,7 @@ Add a script that will automatically run during macOS setup. | Name | Type | In | Description | | ----- | ------ | ----- | ---------------------------------------- | -| team_id | integer | body | _Available in Fleet Premium_. The ID of the team to add the script to. If not specified, a script will be added for hosts with no team. | +| fleet_id | integer | body | _Available in Fleet Premium_. The ID of the fleet to add the script to. If not specified, a script will be added for "Unassigned" hosts. | | script | file | body | The contents of the script to run during setup. | #### Example @@ -7251,7 +7308,7 @@ Add a script that will automatically run during macOS setup. ##### Request body ```http -team_id="1" +fleet_id="1" script="myscript.sh" ``` @@ -7263,7 +7320,7 @@ script="myscript.sh" _Available in Fleet Premium_ -Changes the script that will automatically run during macOS setup. Updates the existing script for the team, or for hosts with no team, if one already exists. +Changes the script that will automatically run during macOS setup. Updates the existing script for the fleet, or for "Unassigned" hosts, if one already exists. > You need to send a request of type `multipart/form-data`. @@ -7271,7 +7328,7 @@ Changes the script that will automatically run during macOS setup. Updates the e | Name | Type | In | Description | | ----- | ------ | ----- | ---------------------------------------- | -| team_id | integer | body | _Available in Fleet Premium_. The ID of the team to add the script to. If not specified, a script will be added for hosts with no team. | +| fleet_id | integer | body | _Available in Fleet Premium_. The ID of the fleet to add the script to. If not specified, a script will be added for "Unassigned" hosts. | | script | file | body | The contents of the script to run during setup. | #### Example @@ -7281,7 +7338,7 @@ Changes the script that will automatically run during macOS setup. Updates the e ##### Request body ```http -team_id="1" +fleet_id="1" script="myscript.sh" ``` @@ -7299,13 +7356,13 @@ Get a script that will automatically run during macOS setup. | Name | Type | In | Description | | ----- | ------ | ----- | ---------------------------------------- | -| team_id | integer | query | _Available in Fleet Premium_. The ID of the team to get the script for. If not specified, script will be returned for hosts with no team. | +| fleet_id | integer | query | _Available in Fleet Premium_. The ID of the fleet to get the script for. If not specified, script will be returned for "Unassigned" hosts. | | alt | string | query | If specified and set to "media", downloads the script's contents. | #### Example (get script) -`GET /api/v1/fleet/setup_experience/script?team_id=3` +`GET /api/v1/fleet/setup_experience/script?fleet_id=3` ##### Default response @@ -7315,6 +7372,7 @@ Get a script that will automatically run during macOS setup. { "id": 1, "team_id": 3, + "fleet_id": 3, "name": "setup-experience-script.sh", "created_at": "2023-07-30T13:41:07Z", "updated_at": "2023-07-30T13:41:07Z" @@ -7323,7 +7381,7 @@ Get a script that will automatically run during macOS setup. #### Example (download script) -`GET /api/v1/fleet/setup_experience/script?team_id=3?alt=media` +`GET /api/v1/fleet/setup_experience/script?fleet_id=3?alt=media` ##### Example response headers @@ -7351,11 +7409,11 @@ Delete a script that will automatically run during macOS setup. | Name | Type | In | Description | | ----- | ------ | ----- | ---------------------------------------- | -| team_id | integer | query | _Available in Fleet Premium_. The ID of the team to get the script for. If not specified, script will be returned for hosts with no team. | +| fleet_id | integer | query | _Available in Fleet Premium_. The ID of the fleet to get the script for. If not specified, script will be returned for "Unassigned" hosts. | #### Example -`DELETE /api/v1/fleet/setup_experience/script?team_id=3` +`DELETE /api/v1/fleet/setup_experience/script?fleet_id=3` ##### Default response @@ -7581,13 +7639,25 @@ None. "name": "πŸ’» Workstations", "id": 1 }, + "macos_fleet": { + "name": "πŸ’» Workstations", + "id": 1 + }, "ios_team": { "name": "πŸ“±πŸ’ Company-owned iPhones", "id": 2 }, + "ios_fleet": { + "name": "πŸ“±πŸ’ Company-owned iPhones", + "id": 2 + }, "ipados_team": { "name": "πŸ”³πŸ’ Company-owned iPads", "id": 3 + }, + "ipados_fleet": { + "name": "πŸ”³πŸ’ Company-owned iPads", + "id": 3 } } ] @@ -7618,7 +7688,7 @@ None. "org_name": "Fleet Device Management Inc.", "location": "https://example.com/mdm/apple/mdm", "renew_date": "2023-11-29T00:00:00Z", - "teams": [ + "fleets": [ { "name": "πŸ’» Workstations", "id": 1 @@ -7706,17 +7776,17 @@ None. ## Policies - [List policies](#list-policies) -- [List team policies](#list-team-policies) +- [List fleet policies](#list-fleet-policies) - [Get policies count](#get-policies-count) -- [Get team policies count](#get-team-policies-count) +- [Get fleet policies count](#get-fleet-policies-count) - [Get policy](#get-policy) -- [Get team policy](#get-team-policy) +- [Get fleet policy](#get-fleet-policy) - [Create policy](#create-policy) -- [Create team policy](#create-team-policy) +- [Create fleet policy](#create-fleet-policy) - [Delete policies](#delete-policies) -- [Delete team policies](#delete-team-policies) +- [Delete fleet policies](#delete-fleet-policies) - [Update policy](#update-policy) -- [Update team policy](#update-team-policy) +- [Update fleet policy](#update-fleet-policy) - [Reset policy automations](#reset-policy-automations) Policies are yes or no questions you can ask about your hosts. @@ -7795,18 +7865,18 @@ For example, a policy might ask β€œIs Gatekeeper enabled on macOS devices?β€œ Th --- -### List team policies +### List fleet policies _Available in Fleet Premium_ -`GET /api/v1/fleet/teams/:id/policies` +`GET /api/v1/fleet/fleets/:id/policies` #### Parameters | Name | Type | In | Description | | ------------------ | ------- | ---- | ------------------------------------------------------------------------------------------------------------- | -| id | integer | path | **Required.** Defines what team ID to operate on | -| merge_inherited | boolean | query | If `true`, will return both team policies **and** inherited ("All teams") policies the `policies` list, and will not return a separate `inherited_policies` list. | +| id | integer | path | **Required.** Defines what fleet ID to operate on | +| merge_inherited | boolean | query | If `true`, will return both fleet policies **and** inherited ("All fleets") policies in the `policies` list, and will not return a separate `inherited_policies` list. | | query | string | query | Search query keywords. Searchable fields include `name`. | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | @@ -7814,7 +7884,7 @@ _Available in Fleet Premium_ #### Example (default usage) -`GET /api/v1/fleet/teams/1/policies` +`GET /api/v1/fleet/fleets/1/policies` ##### Default response @@ -7897,7 +7967,7 @@ _Available in Fleet Premium_ "inherited_policies": [ { "id": 136, - "name": "Arbitrary Test Policy (all platforms) (all teams)", + "name": "Arbitrary Test Policy (all platforms) (all fleets)", "query": "SELECT 1 FROM osquery_info WHERE 1=1;", "description": "If you're seeing this, mostly likely this is because someone is testing out failing policies in dogfood. You can ignore this.", "critical": true, @@ -7919,7 +7989,7 @@ _Available in Fleet Premium_ #### Example (returns single list) -`GET /api/v1/fleet/teams/1/policies?merge_inherited=true` +`GET /api/v1/fleet/fleets/1/policies?merge_inherited=true` ##### Default response @@ -7973,7 +8043,7 @@ _Available in Fleet Premium_ }, { "id": 136, - "name": "Arbitrary Test Policy (all platforms) (all teams)", + "name": "Arbitrary Test Policy (all platforms) (all fleets)", "query": "SELECT 1 FROM osquery_info WHERE 1=1;", "description": "If you're seeing this, mostly likely this is because someone is testing out failing policies in dogfood. You can ignore this.", "critical": true, @@ -8022,22 +8092,22 @@ _Available in Fleet Premium_ --- -### Get team policies count +### Get fleet policies count _Available in Fleet Premium_ -`GET /api/v1/fleet/team/:team_id/policies/count` +`GET /api/v1/fleet/fleets/:fleet_id/policies/count` #### Parameters | Name | Type | In | Description | | ------------------ | ------- | ---- | ------------------------------------------------------------------------------------------------------------- | -| team_id | integer | path | **Required.** Defines what team ID to operate on +| fleet_id | integer | path | **Required.** Defines what fleet ID to operate on | query | string | query | Search query keywords. Searchable fields include `name`. | -| merge_inherited | boolean | query | If `true`, will include inherited ("All teams") policies in the count. | +| merge_inherited | boolean | query | If `true`, will include inherited ("All fleets") policies in the count. | #### Example -`GET /api/v1/fleet/team/1/policies/count` +`GET /api/v1/fleet/fleets/1/policies/count` ##### Default response @@ -8094,22 +8164,22 @@ _Available in Fleet Premium_ --- -### Get team policy +### Get fleet policy _Available in Fleet Premium_ -`GET /api/v1/fleet/teams/:team_id/policies/:policy_id` +`GET /api/v1/fleet/fleets/:fleet_id/policies/:policy_id` #### Parameters | Name | Type | In | Description | | ------------------ | ------- | ---- | ------------------------------------------------------------------------------------------------------------- | -| team_id | integer | path | **Required.** Defines what team ID to operate on | +| fleet_id | integer | path | **Required.** Defines what fleet ID to operate on | | policy_id | integer | path | **Required.** The policy's ID. | #### Example -`GET /api/v1/fleet/teams/1/policies/43` +`GET /api/v1/fleet/fleets/1/policies/43` ##### Default response @@ -8220,21 +8290,21 @@ Only one of `labels_include_any` or `labels_exclude_any` can be specified. If ne --- -### Create team policy +### Create fleet policy _Available in Fleet Premium_ > **Experimental feature**. Software related features (like install software policy automation) are undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. -The semantics for creating a team policy are the same as for global policies, see [Create policy](#create-policy). +The semantics for creating a fleet policy are the same as for global policies, see [Create policy](#create-policy). -`POST /api/v1/fleet/teams/:id/policies` +`POST /api/v1/fleet/fleets/:id/policies` #### Parameters | Name | Type | In | Description | |-------------------| ------- | ---- |--------------------------------------------------------------------------------------------------------------------------------------------------------| -| id | integer | path | Defines what team ID to operate on. | +| id | integer | path | Defines what fleet ID to operate on. | | name | string | body | The policy's name. | | query | string | body | The policy's query in SQL. | | description | string | body | The policy's description. | @@ -8252,7 +8322,7 @@ Only one of `labels_include_any` or `labels_exclude_any` can be specified. If ne #### Example -`POST /api/v1/fleet/teams/1/policies` +`POST /api/v1/fleet/fleets/1/policies` ##### Request body @@ -8283,6 +8353,7 @@ Only one of `labels_include_any` or `labels_exclude_any` can be specified. If ne "author_name": "John", "author_email": "john@example.com", "team_id": 1, + "fleet_id": 1, "resolution": "Resolution steps", "platform": "darwin", "created_at": "2021-12-16T14:37:37Z", @@ -8340,22 +8411,22 @@ Only one of `labels_include_any` or `labels_exclude_any` can be specified. If ne --- -### Delete team policies +### Delete fleet policies _Available in Fleet Premium_ -`POST /api/v1/fleet/teams/:team_id/policies/delete` +`POST /api/v1/fleet/fleets/:fleet_id/policies/delete` #### Parameters | Name | Type | In | Description | | -------- | ------- | ---- | ------------------------------------------------- | -| team_id | integer | path | **Required.** Defines what team ID to operate on | +| fleet_id | integer | path | **Required.** Defines what fleet ID to operate on | | ids | array | body | **Required.** The IDs of the policies to delete. | #### Example -`POST /api/v1/fleet/teams/1/policies/delete` +`POST /api/v1/fleet/fleets/1/policies/delete` ##### Request body @@ -8443,7 +8514,7 @@ Only one of `labels_include_any` or `labels_exclude_any` can be specified. If ne --- -### Update team policy +### Update fleet policy _Available in Fleet Premium_ @@ -8451,13 +8522,13 @@ _Available in Fleet Premium_ > + The `conditional_access_bypass_enabled` setting is experimental, and will be replaced with a reference to the policy's `critical` setting in Fleet 4.83.0. To ensure a seamless upgrade, please avoid enabling bypass for policies marked `critical`. > + Software related features (like install software policy automation) are undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. -`PATCH /api/v1/fleet/teams/:team_id/policies/:policy_id` +`PATCH /api/v1/fleet/fleets/:fleet_id/policies/:policy_id` #### Parameters | Name | Type | In | Description | |-------------------------| ------- | ---- |---------------------------------------------------------------------------------------------------------------------------------------------------------| -| team_id | integer | path | The team's ID. | +| fleet_id | integer | path | The fleet's ID. | | policy_id | integer | path | The policy's ID. | | name | string | body | The query's name. | | query | string | body | The query in SQL. | @@ -8477,7 +8548,7 @@ Only one of `labels_include_any` or `labels_exclude_any` can be specified. If ne #### Example -`PATCH /api/v1/fleet/teams/2/policies/42` +`PATCH /api/v1/fleet/fleets/2/policies/42` ##### Request body @@ -8543,7 +8614,7 @@ Resets [webhook and ticket policy automations](https://fleetdm.com/docs/using-fl | Name | Type | In | Description | | ---------- | -------- | ---- | -------------------------------------------------------- | | policy_ids | array | body | Filters to only run policy automations for the specified policies. | -| team_ids | array | body | _Available in Fleet Premium_. Filters to only run policy automations for hosts in the specified teams. | +| fleet_ids | array | body | _Available in Fleet Premium_. Filters to only run policy automations for hosts in the specified fleets. | #### Example @@ -8555,6 +8626,7 @@ Resets [webhook and ticket policy automations](https://fleetdm.com/docs/using-fl ```json { "team_ids": [1], + "fleet_ids": [1], "policy_ids": [1, 2, 3] } ``` @@ -8569,41 +8641,41 @@ Resets [webhook and ticket policy automations](https://fleetdm.com/docs/using-fl --- -## Queries +## Reports -- [List queries](#list-queries) -- [Get query](#get-query) -- [Get query report](#get-query-report) -- [Get host's query report](#get-hosts-query-report) -- [Create query](#create-query) -- [Update query](#update-query) -- [Delete query by name](#delete-query-by-name) -- [Delete query by ID](#delete-query-by-id) -- [Delete queries](#delete-queries) -- [Run live query](#run-live-query) +- [List reports](#list-reports) +- [Get report](#get-report) +- [Get report data](#get-report-data) +- [Get host's report data](#get-hosts-report-data) +- [Create report](#create-report) +- [Update report](#update-report) +- [Delete report by name](#delete-report-by-name) +- [Delete report by ID](#delete-report-by-id) +- [Delete reports](#delete-reports) +- [Run live report](#run-live-report) -### List queries +### List reports -Returns a list of global queries or team queries. +Returns a list of reports. To see each report's data, use the [get report data](#get-report-data) endpoint. -`GET /api/v1/fleet/queries` +`GET /api/v1/fleet/reports` #### Parameters | Name | Type | In | Description | | --------------- | ------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | -| order_key | string | query | What to order results by. Can be any column in the queries table. | +| order_key | string | query | What to order results by. Can be any column in the reports table. | | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `"asc"` and `"desc"`. Default is `"asc"`. | -| team_id | integer | query | _Available in Fleet Premium_. The ID of the parent team for the queries to be listed. When omitted, returns global queries. | +| fleet_id | integer | query | _Available in Fleet Premium_. The ID of the fleet for the reports to be listed. When omitted, returns global reports. | | query | string | query | Search query keywords. Searchable fields include `name`. | -| merge_inherited | boolean | query | _Available in Fleet Premium_. If `true`, will include global queries in addition to team queries when filtering by `team_id`. (If no `team_id` is provided, this parameter is ignored.) | -| platform | string | query | Return queries that are scheduled to run on this platform. One of: `"macos"`, `"windows"`, `"linux"` (case-insensitive). (Since queries cannot be scheduled to run on `"chrome"` hosts, it's not a valid value here) | +| merge_inherited | boolean | query | _Available in Fleet Premium_. If `true`, will include global reports in addition to fleet-level reports when filtering by `fleet_id`. (If no `fleet_id` is provided, this parameter is ignored.) | +| platform | string | query | Return reports that are scheduled to run on this platform. One of: `"macos"`, `"windows"`, `"linux"` (case-insensitive). (Since reports cannot be scheduled to run on `"chrome"` hosts, it's not a valid value here) | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | #### Example -`GET /api/v1/fleet/queries` +`GET /api/v1/fleet/reports` ##### Default response @@ -8616,8 +8688,49 @@ Returns a list of global queries or team queries. "created_at": "2021-01-04T21:19:57Z", "updated_at": "2021-01-04T21:19:57Z", "id": 1, - "name": "query1", - "description": "query", + "name": "report1", + "description": "report", + "query": "SELECT * FROM osquery_info", + "team_id": null, + "interval": 3600, + "platform": "darwin,windows,linux", + "min_osquery_version": "", + "automations_enabled": true, + "logging": "snapshot", + "saved": true, + "observer_can_run": true, + "discard_data": false, + "author_id": 1, + "author_name": "noah", + "author_email": "noah@example.com", + "labels_include_any": [], + "packs": [ + { + "created_at": "2021-01-05T21:13:04Z", + "updated_at": "2021-01-07T19:12:54Z", + "id": 1, + "name": "Pack", + "description": "Pack", + "platform": "", + "disabled": true + } + ], + "stats": { + "system_time_p50": 1.32, + "system_time_p95": 4.02, + "user_time_p50": 3.55, + "user_time_p95": 3.00, + "total_executions": 3920 + } + } + ], + "reports": [ + { + "created_at": "2021-01-04T21:19:57Z", + "updated_at": "2021-01-04T21:19:57Z", + "id": 1, + "name": "report1", + "description": "report", "query": "SELECT * FROM osquery_info", "team_id": null, "interval": 3600, @@ -8699,21 +8812,21 @@ Returns a list of global queries or team queries. } ``` -### Get query +### Get report -Returns the query specified by ID. +Returns the report specified by ID. -`GET /api/v1/fleet/queries/:id` +`GET /api/v1/fleet/reports/:id` #### Parameters | Name | Type | In | Description | | ---- | ------- | ---- | ------------------------------------------ | -| id | integer | path | **Required**. The id of the desired query. | +| id | integer | path | **Required**. The id of the desired report. | #### Example -`GET /api/v1/fleet/queries/31` +`GET /api/v1/fleet/reports/31` ##### Default response @@ -8763,22 +8876,22 @@ Returns the query specified by ID. } ``` -### Get query report +### Get report data -Returns the query report specified by ID. +Returns a specific report's data. -`GET /api/v1/fleet/queries/:id/report` +`GET /api/v1/fleet/report/:id/report` #### Parameters | Name | Type | In | Description | | --------- | ------- | ----- | ----------------------------------------------------------------------------------------- | | id | integer | path | **Required**. The ID of the desired query. | -| team_id | integer | query | Filter the query report to only include hosts that are associated with the team specified | +| fleet_id | integer | query | Filter the query report to only include hosts that are associated with the fleet specified | #### Example -`GET /api/v1/fleet/queries/31/report` +`GET /api/v1/fleet/reports/31/report` ##### Default response @@ -8787,6 +8900,7 @@ Returns the query report specified by ID. ```json { "query_id": 31, + "report_id": 31, "report_clipped": false, "results": [ { @@ -8847,24 +8961,24 @@ If a query has no results stored, then `results` will be an empty array: } ``` -> Note: osquery scheduled queries do not return errors, so only non-error results are included in the report. If you suspect a query may be running into errors, you can use the [live query](#run-live-query) endpoint to get diagnostics. +> Scheduled reports do not return errors, so only non-error results are included. If you suspect a report may be running into errors, you can use the [live report](#run-live-report) endpoint to get diagnostics. -### Get host's query report +### Get host's report data -Returns a query report for a single host. +Returns a specific report's data for a single host. -`GET /api/v1/fleet/hosts/:id/queries/:query_id` +`GET /api/v1/fleet/hosts/:id/reports/:report_id` #### Parameters | Name | Type | In | Description | | --------- | ------- | ----- | ------------------------------------------ | | id | integer | path | **Required**. The ID of the desired host. | -| query_id | integer | path | **Required**. The ID of the desired query. | +| report_id | integer | path | **Required**. The ID of the desired report. | #### Example -`GET /api/v1/fleet/hosts/123/queries/31` +`GET /api/v1/fleet/hosts/123/reports/31` ##### Default response @@ -8873,6 +8987,7 @@ Returns a query report for a single host. ```json { "query_id": 31, + "report_id": 31, "host_id": 1, "host_name": "foo", "last_fetched": "2021-01-19T17:08:31Z", @@ -8900,11 +9015,12 @@ Returns a query report for a single host. } ``` -If a query has no results stored for the specified host, then `results` will be an empty array: +If a report has no results stored for the specified host, then `results` will be an empty array: ```json { "query_id": 31, + "report_id": 31, "host_id": 1, "host_name": "foo", "last_fetched": "2021-01-19T17:08:31Z", @@ -8913,42 +9029,42 @@ If a query has no results stored for the specified host, then `results` will be } ``` -> Note: osquery scheduled queries do not return errors, so only non-error results are included in the report. If you suspect a query may be running into errors, you can use the [live query](#run-live-query) endpoint to get diagnostics. +> Scheduled reports do not return errors, so only non-error results are included in the report. If you suspect a report may be running into errors, you can use the [live report](#run-live-report) endpoint to get diagnostics. -### Create query +### Create report -Creates a global query or team query. +Creates a global report or fleet report. -`POST /api/v1/fleet/queries` +`POST /api/v1/fleet/reports` #### Parameters | Name | Type | In | Description | | ------------------------------- | ------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| name | string | body | **Required**. The name of the query. | -| query | string | body | **Required**. The query in SQL syntax. | +| name | string | body | **Required**. The name of the report. | +| query | string | body | **Required**. The SQL query for collecting report data. | | description | string | body | The query's description. | -| observer_can_run | boolean | body | Whether or not users with the `observer` role can run the query. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). This field is only relevant for the `observer` role. The `observer_plus` role can run any query and is not limited by this flag (`observer_plus` role was added in Fleet 4.30.0). | -| team_id | integer | body | _Available in Fleet Premium_. The parent team to which the new query should be added. If omitted, the query will be global. | -| interval | integer | body | The amount of time, in seconds, the query waits before running. Can be set to `0` to never run. Default: 0. | -| platform | string | body | The OS platforms where this query will run (other platforms ignored). Comma-separated string. If omitted, runs on all compatible platforms. | -| labels_include_any | array | body | _Available in Fleet Premium_. Labels, specified by label name, to target with this query. If specified, the query will run on hosts that match **any of these** labels. | +| observer_can_run | boolean | body | Whether or not users with the `observer` role can run the report as a live report. This field is only relevant for the `observer` role. The `observer_plus` role can run any report and is not limited by this flag. | +| fleet_id | integer | body | _Available in Fleet Premium_. The fleet to which the new report should be added. If omitted, the report will be global. | +| interval | integer | body | The amount of time, in seconds, the report waits before running. Can be set to `0` to never run. Default: 0. | +| platform | string | body | The OS platforms where this report will run (other platforms ignored). Comma-separated string. If omitted, runs on all compatible platforms. | +| labels_include_any | array | body | _Available in Fleet Premium_. Labels, specified by label name, to target with this report. If specified, the report will run on hosts that match **any of these** labels. | | min_osquery_version | string | body | The minimum required osqueryd version installed on a host. If omitted, all osqueryd versions are acceptable. | -| automations_enabled | boolean | body | Whether to send data to the configured log destination according to the query's `interval`. | -| logging | string | body | The type of log output for this query. Valid values: `"snapshot"`(default), `"differential"`, or `"differential_ignore_removals"`. | -| discard_data | boolean | body | Whether to skip saving the latest query results for each host. Default: `false`. | +| automations_enabled | boolean | body | Whether to send data to the configured log destination according to the report's `interval`. | +| logging | string | body | The type of log output for this report. Valid values: `"snapshot"`(default), `"differential"`, or `"differential_ignore_removals"`. | +| discard_data | boolean | body | Whether to skip saving the latest results for each host. If set to `true`, data is still sent to the configured log destination if `automations_enabled`. Default: `false`. | #### Example -`POST /api/v1/fleet/queries` +`POST /api/v1/fleet/reports` ##### Request body ```json { - "name": "new_query", - "description": "This is a new query.", + "name": "new_report", + "description": "This is a new report.", "query": "SELECT * FROM osquery_info", "interval": 3600, // Once per hour "platform": "darwin,windows,linux", @@ -8991,34 +9107,58 @@ Creates a global query or team query. "labels_include_any": [ "Hosts with Docker installed" ] + }, + "report": { + "created_at": "0001-01-01T00:00:00Z", + "updated_at": "0001-01-01T00:00:00Z", + "id": 288, + "name": "new_query", + "query": "SELECT * FROM osquery_info", + "description": "This is a new query.", + "team_id": null, + "interval": 3600, + "platform": "darwin,windows,linux", + "min_osquery_version": "", + "automations_enabled": true, + "logging": "snapshot", + "saved": true, + "author_id": 1, + "author_name": "", + "author_email": "", + "observer_can_run": true, + "discard_data": false, + "packs": [], + "labels_include_any": [ + "Hosts with Docker installed" + ] } } ``` -### Update query +### Update report -Modifies the query specified by ID. +Modifies the report specified by ID. -`PATCH /api/v1/fleet/queries/:id` +`PATCH /api/v1/fleet/reports/:id` #### Parameters | Name | Type | In | Description | | --------------------------- | ------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| id | integer | path | **Required.** The ID of the query. | -| name | string | body | The name of the query. | -| query | string | body | The query in SQL syntax. | -| description | string | body | The query's description. | -| observer_can_run | boolean | body | Whether or not users with the `observer` role can run the query. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). This field is only relevant for the `observer` role. The `observer_plus` role can run any query and is not limited by this flag (`observer_plus` role was added in Fleet 4.30.0). | -| interval | integer | body | The amount of time, in seconds, the query waits before running. Can be set to `0` to never run. Default: 0. | -| platform | string | body | The OS platforms where this query will run (other platforms ignored). Comma-separated string. If set to "", runs on all compatible platforms. | -| labels_include_any | list | body | _Available in Fleet Premium_. Labels, specified by label name, to target with this query. If specified, the query will run on hosts that match **any of these** labels. | +| id | integer | path | **Required.** The ID of the report. | +| name | string | body | The name of the report. | +| query | string | body | The report's SQL query. | +| description | string | body | The report's description. | +| observer_can_run | boolean | body | Whether or not users with the `observer` role can run the report as a live report. This field is only relevant for the `observer` role. The `observer_plus` role can run any query and is not limited by this flag. | +| interval | integer | body | The amount of time, in seconds, the report waits before running. Can be set to `0` to never run. Default: 0. | +| platform | string | body | The OS platforms where this report will run (other platforms ignored). Comma-separated string. If set to "", runs on all compatible platforms. | +| labels_include_any | list | body | _Available in Fleet Premium_. Labels, specified by label name, to target with this report. If specified, the report will run on hosts that match **any of these** labels. | | min_osquery_version | string | body | The minimum required osqueryd version installed on a host. If omitted, all osqueryd versions are acceptable. | -| automations_enabled | boolean | body | Whether to send data to the configured log destination according to the query's `interval`. | +| automations_enabled | boolean | body | Whether to send data to the configured log destination according to the report's `interval`. | | logging | string | body | The type of log output for this query. Valid values: `"snapshot"`(default), `"differential"`, or `"differential_ignore_removals"`. | -| discard_data | boolean | body | Whether to skip saving the latest query results for each host. | +| discard_data | boolean | body | Whether to skip saving the latest results for each host. If set to `true`, data is still sent to the configured log destination if `automations_enabled`. | -> Note that any of the following conditions will cause the existing query report to be deleted: +> Note that any of the following conditions will cause the existing report's data to be discarded: > - Updating the `query` (SQL) field > - Updating the filters for targeted hosts (`platform`, `min_osquery_version`, `labels_include_any`) > - Changing `discard_data` from `false` to `true` @@ -9026,13 +9166,13 @@ Modifies the query specified by ID. #### Example -`PATCH /api/v1/fleet/queries/2` +`PATCH /api/v1/fleet/reports/2` ##### Request body ```json { - "name": "new_title_for_my_query", + "name": "new_title_for_my_report", "interval": 3600, // Once per hour, "platform": "", "min_osquery_version": "", @@ -9074,68 +9214,92 @@ Modifies the query specified by ID. "Hosts with Docker installed", "macOS 13+" ] + }, + "report": { + "created_at": "2021-01-22T17:23:27Z", + "updated_at": "2021-01-22T17:23:27Z", + "id": 288, + "name": "new_title_for_my_query", + "description": "This is a new query.", + "query": "SELECT * FROM osquery_info", + "team_id": null, + "interval": 3600, + "platform": "", + "min_osquery_version": "", + "automations_enabled": false, + "logging": "snapshot", + "saved": true, + "author_id": 1, + "author_name": "noah", + "observer_can_run": true, + "discard_data": true, + "packs": [], + "labels_include_any": [ + "Hosts with Docker installed", + "macOS 13+" + ] } } ``` -### Delete query by name +### Delete report by name -Deletes the query specified by name. +Deletes the report specified by name. -`DELETE /api/v1/fleet/queries/:name` +`DELETE /api/v1/fleet/reports/:name` #### Parameters | Name | Type | In | Description | | ---- | ---------- | ---- | ------------------------------------ | -| name | string | path | **Required.** The name of the query. | -| team_id | integer | body | _Available in Fleet Premium_. The ID of the parent team of the query to be deleted. If omitted, Fleet will search among queries in the global context. | +| name | string | path | **Required.** The name of the report. | +| fleet_id | integer | body | _Available in Fleet Premium_. The ID of the report's fleet. If omitted, Fleet will search among only global reports. | #### Example -`DELETE /api/v1/fleet/queries/foo` +`DELETE /api/v1/fleet/reports/foo` ##### Default response `Status: 200` -### Delete query by ID +### Delete report by ID -Deletes the query specified by ID. +Deletes the report specified by ID. -`DELETE /api/v1/fleet/queries/id/:id` +`DELETE /api/v1/fleet/reports/id/:id` #### Parameters | Name | Type | In | Description | | ---- | ------- | ---- | ---------------------------------- | -| id | integer | path | **Required.** The ID of the query. | +| id | integer | path | **Required.** The ID of the report. | #### Example -`DELETE /api/v1/fleet/queries/id/28` +`DELETE /api/v1/fleet/reports/id/28` ##### Default response `Status: 200` -### Delete queries +### Delete reports -Deletes the queries specified by ID. Returns the count of queries successfully deleted. +Deletes the reports specified by ID. Returns the count of reports successfully deleted. -`POST /api/v1/fleet/queries/delete` +`POST /api/v1/fleet/reports/delete` #### Parameters | Name | Type | In | Description | | ---- | ----- | ---- | ------------------------------------- | -| ids | array | body | **Required.** The IDs of the queries. | +| ids | array | body | **Required.** The IDs of the reports. | #### Example -`POST /api/v1/fleet/queries/delete` +`POST /api/v1/fleet/reports/delete` ##### Request body @@ -9157,27 +9321,27 @@ Deletes the queries specified by ID. Returns the count of queries successfully d } ``` -### Run live query +### Run live report > This updated API endpoint replaced `GET /api/v1/fleet/queries/run` in Fleet 4.43.0, for improved compatibility with many HTTP clients. The [deprecated endpoint](https://github.com/fleetdm/fleet/blob/fleet-v4.42.0/docs/REST%20API/rest-api.md#run-live-query) is maintained for backwards compatibility. -Runs a live query against the specified hosts and responds with the results. +Runs a live report against the specified hosts and responds with the results. -The live query will stop if the request times out. Timeouts happen if targeted hosts haven't responded after the configured `FLEET_LIVE_QUERY_REST_PERIOD` (default 25 seconds) or if the `distributed_interval` agent option (default 10 seconds) is higher than the `FLEET_LIVE_QUERY_REST_PERIOD`. +The live report will stop if the request times out. Timeouts happen if targeted hosts haven't responded after the configured `FLEET_LIVE_QUERY_REST_PERIOD` (default 25 seconds) or if the `distributed_interval` agent option (default 10 seconds) is higher than the `FLEET_LIVE_QUERY_REST_PERIOD`. -`POST /api/v1/fleet/queries/:id/run` +`POST /api/v1/fleet/reports/:id/run` #### Parameters | Name | Type | In | Description | |-----------|-------|------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| query_id | integer | path | **Required**. The ID of the saved query to run. | +| report_id | integer | path | **Required**. The ID of the saved report to run. | | host_ids | array | body | **Required**. The IDs of the hosts to target. User must be authorized to target all of these hosts. | #### Example -`POST /api/v1/fleet/queries/123/run` +`POST /api/v1/fleet/reports/123/run` ##### Request body @@ -9192,6 +9356,7 @@ The live query will stop if the request times out. Timeouts happen if targeted h ```json { "query_id": 123, + "report_id": 123, "targeted_host_count": 4, "responded_host_count": 2, "results": [ @@ -9230,7 +9395,7 @@ The live query will stop if the request times out. Timeouts happen if targeted h The [schedule API endpoints](https://github.com/fleetdm/fleet/blob/f6631e27f56b6704c555adfb7a3bb8c6d1a74d98/docs/REST%20API/rest-api.md#schedule) are deprecated as of Fleet 4.35. They are maintained for backwards compatibility. -Please use the [queries](#queries) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. +Please use the [reports](#reports) endpoints, which as of 4.35 have attributes such as `interval` and `platform` that enable scheduling. --- @@ -9268,9 +9433,9 @@ By default, script runs time out after 5 minutes. You can modify this default in | ---- | ------- | ---- | -------------------------------------------- | | host_id | integer | body | **Required**. The ID of the host to run the script on. | | script_id | integer | body | The ID of the existing saved script to run. Only one of either `script_id`, `script_contents`, or `script_name` can be included. | -| script_contents | string | body | The contents of the script to run. Only one of either `script_id`, `script_contents`, or `script_name` can be included. Scripts must be less than 10,000 characters. To run scripts with more than 10k characters, save the script and use `script_id` or `script_name` and `team_id` instead. | -| script_name | integer | body | The name of the existing saved script to run. If specified, requires `team_id`. Only one of either `script_id`, `script_contents`, or `script_name` can be included in the request. | -| team_id | integer | body | The ID of the existing saved script to run. If specified, requires `script_name`. Only one of either `script_id`, `script_contents`, or `script_name` can be included in the request. | +| script_contents | string | body | The contents of the script to run. Only one of either `script_id`, `script_contents`, or `script_name` can be included. Scripts must be less than 10,000 characters. To run scripts with more than 10k characters, save the script and use `script_id` or `script_name` and `fleet_id` instead. | +| script_name | integer | body | The name of the existing saved script to run. If specified, requires `fleet_id`. Only one of either `script_id`, `script_contents`, or `script_name` can be included in the request. | +| fleet_id | integer | body | The ID of the fleet the existing saved script belongs to. If specified, requires `script_name`. Only one of either `script_id`, `script_contents`, or `script_name` can be included in the request. | > Note that if any combination of `script_id`, `script_contents`, and `script_name` are included in the request, this endpoint will respond with an error. @@ -9352,7 +9517,7 @@ The script will be added to each host's list of upcoming activities. | query | string | Search query keywords. Searchable fields include `hostname`, `hardware_serial`, `uuid`, and `ipv4`. | | status | string | Host status. Can either be `new`, `online`, `offline`, `mia` or `missing`. | | label_id | number | ID of a label to filter by. | -| team_id | number | ID of the team to filter by. | +| fleet_id | number | ID of the fleet to filter by. | > Note that if a batch script is scheduled for the future using `not_before`, and hosts are targeted using `filters`, the script will run on any hosts matching the filters _at the time the batch script was added_. To see all targeted hosts, use the [List hosts targeted in batch script](#list-hosts-targeted-in-batch-script) endpoint. @@ -9405,7 +9570,7 @@ Returns a list of batch script executions. | Name | Type | In | Description | | ---- | ------- | ---- | -------------------------------------------- | -| team_id | integer | query | _Available in Fleet Premium_. Filters to batch script runs for the specified team. | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters to batch script runs for the specified fleet. | | status | string | query | Filters to batch script runs with this status. Either `"started"`, `"scheduled"`, or `"finished"`. | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | @@ -9420,6 +9585,7 @@ Returns a list of batch script executions. ```json { "team_id": 123, + "fleet_id": 123, "status": "completed" } ``` @@ -9436,6 +9602,7 @@ Returns a list of batch script executions. "script_name": "my-script.sh", "batch_execution_id": "e797d6c6-3aae-11ee-be56-0242ac120002", "team_id": 123, + "fleet_id": 123, "not_before": "2025-07-01T15:00:00Z", "finished_at": "2025-07-06T15:00:00Z", "started_at": "2025-07-06T14:00:00Z", @@ -9489,6 +9656,7 @@ Returns a summary of a batch-run script, including host counts and current statu "script_id": 555, "script_name": "my-script.sh", "team_id": 123, + "fleet_id": 123, "not_before": "2025-07-01T15:00:00Z", "finished_at": "2025-07-06T15:00:00Z", "started_at": "2025-07-06T14:00:00Z", @@ -9555,7 +9723,7 @@ Returns a list hosts targeted in a batch script run, along with their script exe ### Create script -Uploads a script, making it available to run on hosts assigned to the specified team (or no team). +Uploads a script, making it available to run on hosts assigned to the specified fleet (or "Unassigned"). > You need to send a request of type `multipart/form-data`. @@ -9568,7 +9736,7 @@ Uploads a script, making it available to run on hosts assigned to the specified | Name | Type | In | Description | | ---- | ------- | ---- | -------------------------------------------- | | script | file | body | **Required**. The file containing the script. | -| team_id | integer | body | _Available in Fleet Premium_. The team ID. If specified, the script will only be available to hosts assigned to this team. If not specified, the script will only be available to hosts on **no team**. | +| fleet_id | integer | body | _Available in Fleet Premium_. The fleet ID. If specified, the script will only be available to hosts assigned to this fleet. If not specified, the script will only be available for "Unassigned" hosts. | Script line endings are automatically converted from [CRLF to LF](https://en.wikipedia.org/wiki/Newline) for compatibility with both non-Windows shells and PowerShell. @@ -9579,7 +9747,7 @@ Script line endings are automatically converted from [CRLF to LF](https://en.wik ##### Request body ```http -team_id="1" +fleet_id="1" script="myscript.sh" ``` @@ -9661,7 +9829,7 @@ Deletes an existing script. | Name | Type | In | Description | | --------------- | ------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | -| team_id | integer | query | _Available in Fleet Premium_. The ID of the team to filter scripts by. If not specified, it will filter only scripts that are available to hosts with no team. | +| fleet_id | integer | query | _Available in Fleet Premium_. The ID of the fleet to filter scripts by. If not specified, it will filter only scripts that are available for "Unassigned" hosts. | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | @@ -9904,7 +10072,7 @@ Get a list of all software. | order_key | string | query | What to order results by. Allowed fields are `name` and `hosts_count`. Default is `hosts_count` (descending). | | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `"asc"` and `"desc"`. Default is `"asc"`. | | query | string | query | Search query keywords. Searchable fields include `title` and `cve`. | -| team_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified team. Use `0` to filter by hosts assigned to "No team". | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified fleet. Use `0` to filter by "Unassigned" hosts. | | vulnerable | boolean | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | | available_for_install | boolean | query | If `true` or `1`, only list software that is available for install (added by the user). Default is `false`. | | self_service | boolean | query | If `true` or `1`, only lists self-service software. Default is `false`. | @@ -9912,15 +10080,15 @@ Get a list of all software. | min_cvss_score | integer | query | _Available in Fleet Premium_. Filters to include only software with vulnerabilities that have a CVSS version 3.x base score higher than the specified value. | | max_cvss_score | integer | query | _Available in Fleet Premium_. Filters to only include software with vulnerabilities that have a CVSS version 3.x base score lower than what's specified. | | exploit | boolean | query | _Available in Fleet Premium_. If `true`, filters to only include software with vulnerabilities that have been actively exploited in the wild (`cisa_known_exploit: true`). Default is `false`. | -| platform | string | query | Filters software titles available for install by platforms. `team_id` must be specified to filter by platform. Options are: `"macos"` (alias of `"darwin"`), `"darwin"` `"windows"`, `"linux"`, `"chrome"`, `"ios"`, `"ipados"`. To show titles from multiple platforms, separate the platforms with commas (e.g. `?platform=darwin,windows`). | -| hash_sha256 | string | query | Filters to only include custom software packages (uploaded installers) with the specified SHA-256 hash. `team_id` must be specified to filter by hash. This allows checking if a specific package already exists before uploading. | -| package_name | string | query | Filters to only include custom software packages (uploaded installers) with the specified package filename. `team_id` must be specified to filter by package name. This allows checking if a specific package already exists before uploading. | +| platform | string | query | Filters software titles available for install by platforms. `fleet_id` must be specified to filter by platform. Options are: `"macos"` (alias of `"darwin"`), `"darwin"` `"windows"`, `"linux"`, `"chrome"`, `"ios"`, `"ipados"`. To show titles from multiple platforms, separate the platforms with commas (e.g. `?platform=darwin,windows`). | +| hash_sha256 | string | query | Filters to only include custom software packages (uploaded installers) with the specified SHA-256 hash. `fleet_id` must be specified to filter by hash. This allows checking if a specific package already exists before uploading. | +| package_name | string | query | Filters to only include custom software packages (uploaded installers) with the specified package filename. `fleet_id` must be specified to filter by package name. This allows checking if a specific package already exists before uploading. | | exclude_fleet_maintained_apps | boolean | query | If `true` or `1`, Fleet maintained apps will not be included in the list of `software_titles`. Default is `false` | #### Example -`GET /api/v1/fleet/software/titles?team_id=3&platform=darwin,windows` +`GET /api/v1/fleet/software/titles?fleet_id=3&platform=darwin,windows` ##### Default response @@ -9935,7 +10103,7 @@ Get a list of all software. "id": 12, "name": "Firefox.app", "display_name": "Firefox", - "icon_url":"/api/latest/fleet/software/titles/12/icon?team_id=3", + "icon_url":"/api/latest/fleet/software/titles/12/icon?fleet_id=3", "display_name": "", "software_package": { "platform": "darwin", @@ -10102,7 +10270,7 @@ Get a list of all software versions. | order_key | string | query | What to order results by. Allowed fields are `name`, `hosts_count`, `cve_published`, `cvss_score`, `epss_probability` and `cisa_known_exploit`. Default is `hosts_count` (descending). | | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `"asc"` and `"desc"`. Default is `"asc"`. | | query | string | query | Search query keywords. Searchable fields include `name`, `version`, and `cve`. | -| team_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified team. Use `0` to filter by hosts assigned to "No team". | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters the software to only include the software installed on the hosts that are assigned to the specified fleet. Use `0` to filter by "Unassigned" hosts. | | vulnerable | boolean | query | If true or 1, only list software that has detected vulnerabilities. Default is `false`. | | min_cvss_score | integer | query | _Available in Fleet Premium_. Filters to include only software with vulnerabilities that have a CVSS version 3.x base score higher than the specified value. | | max_cvss_score | integer | query | _Available in Fleet Premium_. Filters to only include software with vulnerabilities that have a CVSS version 3.x base score lower than what's specified. | @@ -10191,7 +10359,7 @@ Returns a list of all operating systems. | Name | Type | In | Description | | --- | --- | --- | --- | -| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified fleet. Use `0` to filter by "Unassigned" hosts. | | platform | string | query | Filters the hosts to the specified platform | | os_name | string | query | The name of the operating system to filter hosts by. `os_version` must also be specified with `os_name` | | os_version | string | query | The version of the operating system to filter hosts by. `os_name` must also be specified with `os_version` | @@ -10256,11 +10424,11 @@ Returns information about the specified software. By default, `versions` are sor | Name | Type | In | Description | | ---- | ---- | -- | ----------- | | id | integer | path | **Required.** The software title's ID. | -| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified fleet. Use `0` to filter by "Unassigned" hosts. | #### Example -`GET /api/v1/fleet/software/titles/12?team_id=3` +`GET /api/v1/fleet/software/titles/12?fleet_id=3` ##### Default response @@ -10352,7 +10520,7 @@ Returns information about the specified software. By default, `versions` are sor #### Example (app store app) -`GET /api/v1/fleet/software/titles/15?team_id=3` +`GET /api/v1/fleet/software/titles/15?fleet_id=3` ##### Default response @@ -10364,7 +10532,7 @@ Returns information about the specified software. By default, `versions` are sor "id": 15, "name": "Logic Pro", "display_name": "", - "icon_url": "/api/latest/fleet/software/titles/15/icon?team_id=3", + "icon_url": "/api/latest/fleet/software/titles/15/icon?fleet_id=3", "display_name": "", "bundle_identifier": "com.apple.logic10", "software_package": null, @@ -10405,7 +10573,7 @@ Returns information about the specified software. By default, `versions` are sor } ``` -`auto_update_enabled`, `auto_update_window_start` and `auto_update_window_end` will only be returned for iOS/iPadOS apps, and only when a `team_id` is specified in the request. +`auto_update_enabled`, `auto_update_window_start` and `auto_update_window_end` will only be returned for iOS/iPadOS apps, and only when a `fleet_id` is specified in the request. #### Example (Play Store app) @@ -10460,7 +10628,7 @@ Returns information about the specified software. By default, `versions` are sor #### Example (in-house iOS app) -`GET /api/v1/fleet/software/titles/24?team_id=3` +`GET /api/v1/fleet/software/titles/24?fleet_id=3` ##### Default response @@ -10523,7 +10691,7 @@ Returns information about the specified software version. | Name | Type | In | Description | | ---- | ---- | -- | ----------- | | id | integer | path | **Required.** The software version's ID. | -| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified fleet. Use `0` to filter by "Unassigned" hosts. | #### Example @@ -10583,7 +10751,7 @@ Retrieves information about the specified operating system (OS) version. | Name | Type | In | Description | | ---- | ---- | -- | ----------- | | id | integer | path | **Required.** The OS version's ID. | -| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified fleet. Use `0` to filter by "Unassigned" hosts. | | max_vulnerabilities | integer | query | Limits the number of `vulnerabilities` returned. (If omitted, returns all vulnerabilities.) For Linux OS's, doesn't limit the number of vulnerabilities returned in the `kernels` array. | ##### Default response @@ -10701,7 +10869,7 @@ Add a package (.pkg, .msi, .exe, .deb, .rpm, .tar.gz, .ipa) to install on Apple | Name | Type | In | Description | | ---- | ------- | ---- | -------------------------------------------- | | software | file | body | **Required**. Installer package file or custom script file. Supported packages are `.pkg`, `.msi`, `.exe`, `.deb`, `.rpm`, `.tar.gz`, `.ipa`, `.sh`, and `.ps1`. | -| team_id | integer | body | The team ID. Adds a software package to the specified team. If not specified, it will add the software for hosts with no team. | +| fleet_id | integer | body | The fleet ID. Adds a software package to the specified fleet. If not specified, it will add the software for "Unassigned" hosts. | | install_script | string | body | Script that Fleet runs to install software. If not specified Fleet runs the [default install script](https://github.com/fleetdm/fleet/tree/main/pkg/file/scripts) for each package type if one exists. Required for `.tar.gz` and `.exe` (no default script). Not supported for `.sh` and `.ps1`. | | uninstall_script | string | body | Script that Fleet runs to uninstall software. If not specified Fleet runs the [default uninstall script](https://github.com/fleetdm/fleet/tree/main/pkg/file/scripts) for each package type if one exists. Required for `.tar.gz` and `.exe` (no default script). Not supported for `.sh` and `.ps1`. | | pre_install_query | string | body | Query that is pre-install condition. If the query doesn't return any result, Fleet won't proceed to install. Not supported for `.sh` and `.ps1`. | @@ -10724,7 +10892,7 @@ POST /api/v1/fleet/software/package ##### Request body ``` -team_id="1" +fleet_id="1" self_service="true" install_script="sudo installer -pkg /temp/FalconSensor-6.44.pkg -target /" pre_install_query"SELECT 1 FROM macos_profiles WHERE uuid='c9f4f0d5-8426-4eb8-b61b-27c543c9d3db';" @@ -10790,7 +10958,7 @@ Update a package to install on macOS, Windows, Linux, iOS, or iPadOS hosts. | ---- | ------- | ---- | -------------------------------------------- | | id | integer | path | ID of the software title being updated. | | software | file | body | Installer package file or custom script file. Supported packages are `.pkg`, `.msi`, `.exe`, `.deb`, `.rpm`, `.tar.gz`, `.ipa`, `.sh`, and `.ps1`. | -| team_id | integer | body | **Required**. The team ID. Updates a software package in the specified team. | +| fleet_id | integer | body | **Required**. The fleet ID. Updates a software package in the specified fleet. | | display_name | string | body | Optional override for the default `name`. | | categories | array | body | Zero or more of the [supported categories](https://fleetdm.com/docs/configuration/yaml-files#supported-software-categories), used to group self-service software on your end users' **Fleet Desktop > My device** page. Software with no categories will be still be shown under **All**. | | install_script | string | body | Command that Fleet runs to install software. If not specified Fleet runs the [default install command](https://github.com/fleetdm/fleet/tree/main/pkg/file/scripts) for each package type. Not supported for `.sh` and `.ps1`. | @@ -10813,7 +10981,7 @@ Add the `X-Fleet-Scripts-Encoded: base64` header line to parse `install_script`, ##### Request body ```http -team_id="1" +fleet_id="1" software="FalconSensor-6.44.pkg" self_service="true" display_name="CrowdStrike agent" @@ -10870,7 +11038,7 @@ Icon will be displayed in Fleet and on **Fleet Desktop > Self-service**. In the | Name | Type | In | Description | | ---- | ------- | ---- | -------------------------------------------- | | id | integer | path | ID of the software title being updated. | -| team_id | integer | query | **Required**. The team ID. Updates a software icon in the specified team. | +| fleet_id | integer | query | **Required**. The fleet ID. Updates a software icon in the specified fleet. | | icon | file | body | Must be PNG format. It must be square with dimensions between 120x120 px and 1024x1024 px. | | hash_sha256 | string | body | SHA256 hash of an already-uploaded icon to use. If provided, `filename` is required and `icon` should be omitted. | | filename | string | body | Filename to record for the icon image, if `hash_sha256` was supplied. | @@ -10908,7 +11076,7 @@ Download the icon added via [Update software icon](#update-software-icon) or ico | Name | Type | In | Description | | ---- | ------- | ---- | -------------------------------------------- | | id | integer | path | ID of the software title to get icon for. | -| team_id | integer | query | **Required**. The team ID. | +| fleet_id | integer | query | **Required**. The fleet ID. | This endpoint will redirect (302) to the Apple-hosted URL of an icon if an icon override isn't set and a VPP app is added for the title on the host's team. @@ -10958,7 +11126,7 @@ Delete a custom icon added via [Update software icon](#update-software-icon). Th > **Experimental feature**. This feature is undergoing rapid improvement, which may result in breaking changes to the API or configuration surface. It is not recommended for use in automated workflows. -Returns the list of Apple App Store (VPP) apps that can be added to the specified team. If an app is already added to the team, it's excluded from the list. +Returns the list of Apple App Store (VPP) apps that can be added to the specified fleet. If an app is already added to the fleet, it's excluded from the list. `GET /api/v1/fleet/software/app_store_apps` @@ -10966,11 +11134,11 @@ Returns the list of Apple App Store (VPP) apps that can be added to the specifie | Name | Type | In | Description | | ------- | ---- | -- | ----------- | -| team_id | integer | query | **Required**. The team ID. | +| fleet_id | integer | query | **Required**. The fleet ID. | #### Example -`GET /api/v1/fleet/software/app_store_apps/?team_id=3` +`GET /api/v1/fleet/software/app_store_apps/?fleet_id=3` ##### Default response @@ -11022,7 +11190,7 @@ Add Apple App Store or Google Play store app. Apple apps must be added in Apple | Name | Type | In | Description | | ---- | ---- | -- | ----------- | | app_store_id | string | body | **Required.** The ID of the Apple App Store app or Google Play app. | -| team_id | integer | body | **Required**. The team ID. Adds app from the store to the specified team. | +| fleet_id | integer | body | **Required**. The fleet ID. Adds app from the store to the specified fleet. | | platform | string | body | The platform of the app (`darwin`, `ios`, `ipados`, or `android`). Default is `darwin`. | | self_service | boolean | body | **Required if platform is Android**. Currently supported for macOS and Android apps. Specifies whether the app shows up in self-service and is available for install by the end user. For macOS shows up on **Fleet Desktop > My device** page, for Android in **Play Store** app in end user's work profile, and for iOS/iPadOS in [self-service web](https://fleetdm.com/learn-more-about/deploy-self-service-to-ios) app. | | ensure | string | form | For macOS only, if set to "present" (currently the only valid value if set), create a policy that triggers a software install only on hosts missing the software. | @@ -11084,7 +11252,7 @@ Modify an Apple App Store (VPP) or a Google Play app's options. | Name | Type | In | Description | | ---- | ---- | -- | ----------- | -| team_id | integer | body | **Required**. The team ID. Edits Apple App Store or Android Play store app from the specified team. | +| fleet_id | integer | body | **Required**. The fleet ID. Edits Apple App Store or Android Play store app from the specified fleet. | | display_name | string | body | Optional override for the default `name`. | | self_service | boolean | body | **Required if platform is Android**. Currently supported for macOS and Android apps. Specifies whether the app shows up in self-service and is available for install by the end user. For macOS shows up on **Fleet Desktop > My device** page, and for Android in **Play Store** app in end user's work profile. | | categories | array | body | Zero or more of the [supported categories](https://fleetdm.com/docs/configuration/yaml-files#supported-software-categories), used to group self-service software on your end users' **Fleet Desktop > My device** page. Software with no categories will be still be shown under **All**. | @@ -11169,13 +11337,13 @@ List available Fleet-maintained apps. | Name | Type | In | Description | | ---- | ---- | -- | ----------- | -| team_id | integer | query | If specified, each app includes the `software_title_id` if the software has already been added to that team. | +| fleet_id | integer | query | If specified, each app includes the `software_title_id` if the software has already been added to that fleet. | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | #### Example -`GET /api/v1/fleet/software/fleet_maintained_apps?team_id=3` +`GET /api/v1/fleet/software/fleet_maintained_apps?fleet_id=3` ##### Default response @@ -11230,7 +11398,7 @@ Returns information about the specified Fleet-maintained app. | Name | Type | In | Description | | ---- | ---- | -- | ----------- | | id | integer | path | **Required.** The Fleet-maintained app's ID. | -| team_id | integer | query | If supplied, set `software_title_id` on the response when an installer or VPP app has already been added to that team for that software. | +| fleet_id | integer | query | If supplied, set `software_title_id` on the response when an installer or VPP app has already been added to that fleet for that software. | #### Example @@ -11273,7 +11441,7 @@ Add Fleet-maintained app so it's available for install. | Name | Type | In | Description | | ---- | ---- | -- | ----------- | | fleet_maintained_app_id | integer | body | **Required.** The ID of Fleet-maintained app. | -| team_id | integer | body | **Required**. The team ID. Adds Fleet-maintained app to the specified team. | +| fleet_id | integer | body | **Required**. The fleet ID. Adds Fleet-maintained app to the specified fleet. | | install_script | string | body | Command that Fleet runs to install software. If not specified Fleet runs default install command for each Fleet-maintained app. | | pre_install_query | string | body | Query that is pre-install condition. If the query doesn't return any result, Fleet won't proceed to install. | | post_install_script | string | body | The contents of the script to run after install. If the specified script fails (exit code non-zero) software install will be marked as failed and rolled back. | @@ -11323,7 +11491,7 @@ _Available in Fleet Premium._ | Name | Type | In | Description | | ---- | ------- | ---- | -------------------------------------------- | | id | integer | path | **Required**. The ID of the software title to download software package.| -| team_id | integer | query | **Required**. The team ID. Downloads a software package added to the specified team. | +| fleet_id | integer | query | **Required**. The fleet ID. Downloads a software package added to the specified fleet. | | alt | string | query | **Required**. If specified and set to `"media"`, downloads the specified software package. | #### Example @@ -11445,7 +11613,7 @@ _Available in Fleet Premium._ | Name | Type | In | Description | | ---- | ------- | ---- | -------------------------------------------- | | software_title_id | integer | path | **Required**. The ID of the software title to download software package.| -| team_id | integer | query | **Required**. The team ID. Downloads a software package added to the specified team. | +| fleet_id | integer | query | **Required**. The fleet ID. Downloads a software package added to the specified fleet. | | alt | integer | query | **Required**. If specified and set to "media", downloads the specified software package. | #### Example @@ -11479,7 +11647,7 @@ Deletes software that's available for install. This won't uninstall the software | Name | Type | In | Description | | ---- | ------- | ---- | -------------------------------------------- | | software_title_id | integer | path | **Required**. The ID of the software title to delete software available for install. | -| team_id | integer | query | **Required**. The team ID. Deletes a software package added to the specified team. | +| fleet_id | integer | query | **Required**. The fleet ID. Deletes a software package added to the specified fleet. | #### Example @@ -11504,7 +11672,7 @@ Retrieves a list of all CVEs affecting software and/or OS versions. | Name | Type | In | Description | | --- | --- | --- | --- | -| team_id | integer | query | _Available in Fleet Premium_. Filters only include vulnerabilities affecting the specified team. Use `0` to filter by hosts assigned to "No team". | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters only include vulnerabilities affecting the specified fleet. Use `0` to filter by "Unassigned" hosts. | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | | order_key | string | query | What to order results by. Allowed fields are: `cve`, `cvss_score`, `epss_probability`, `cve_published`, `created_at`, and `host_count`. Default is `created_at` (descending). | @@ -11554,7 +11722,7 @@ If no vulnerable OS versions or software were found, but Fleet is aware of the v | Name | Type | In | Description | |---------|---------|-------|------------------------------------------------------------------------------------------------------------------------------| | cve | string | path | The cve to get information about (format must be CVE-YYYY-<4 or more digits>, case-insensitive). | -| team_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified team. Use `0` to filter by hosts assigned to "No team". | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters response data to the specified fleet. Use `0` to filter by "Unassigned" hosts. | `GET /api/v1/fleet/vulnerabilities/:cve` @@ -11613,7 +11781,7 @@ The `extension_for` field is included when set and when empty, at the same level ## Targets -In Fleet, targets are used to run queries against specific hosts or groups of hosts. Labels are used to create groups in Fleet. +In Fleet, targets are used to run reports against specific hosts or groups of hosts. Labels are used to create groups in Fleet. ### Search targets @@ -11629,7 +11797,7 @@ The returned lists are filtered based on the hosts the requesting user has acces | -------- | ------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | query | string | body | The search query. Searchable items include a host's hostname or IPv4 address and labels. | | query_id | integer | body | The saved query (if any) that will be run. The `observer_can_run` property on the query and the user's roles effect which targets are included. | -| selected | object | body | The targets already selected. The object includes a `hosts` property which contains a list of host IDs, a `labels` with label IDs and/or a `teams` property with team IDs. | +| selected | object | body | The targets already selected. The object includes a `hosts` property which contains a list of host IDs, a `labels` with label IDs and/or a `fleets` property with fleet IDs. | #### Example @@ -11752,7 +11920,7 @@ The returned lists are filtered based on the hosts the requesting user has acces "count": 5 } ], - "teams": [ + "fleets": [ { "id": 1, "created_at": "2021-05-27T20:02:20Z", @@ -11775,21 +11943,21 @@ The returned lists are filtered based on the hosts the requesting user has acces --- -## Teams +## Fleets -- [List teams](#list-teams) -- [Get team](#get-team) -- [Create team](#create-team) -- [Update team](#update-team) -- [Add users to team](#add-users-to-team) -- [Update team's agent options](#update-teams-agent-options) -- [Delete team](#delete-team) +- [List fleets](#list-fleets) +- [Get fleet](#get-fleet) +- [Create fleet](#create-fleet) +- [Update fleet](#update-fleet) +- [Add users to fleet](#add-users-to-fleet) +- [Update fleet's agent options](#update-fleets-agent-options) +- [Delete fleet](#delete-fleet) -### List teams +### List fleets _Available in Fleet Premium_ -`GET /api/v1/fleet/teams` +`GET /api/v1/fleet/fleets` #### Parameters @@ -11797,13 +11965,13 @@ _Available in Fleet Premium_ | --------------- | ------- | ----- | ----------------------------------------------------------------------------------------------------------------------------- | | page | integer | query | Page number of the results to fetch. | | per_page | integer | query | Results per page. | -| order_key | string | query | What to order results by. Can be any column in the `teams` table. | +| order_key | string | query | What to order results by. Can be any column in the `fleets` table. | | order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `"asc"` and `"desc"`. Default is `"asc"`. | | query | string | query | Search query keywords. Searchable fields include `name`. | #### Example -`GET /api/v1/fleet/teams` +`GET /api/v1/fleet/fleets` ##### Default response @@ -11811,7 +11979,7 @@ _Available in Fleet Premium_ ```json { - "teams": [ + "fleets": [ { "id": 1, "created_at": "2021-07-28T15:58:21Z", @@ -11890,25 +12058,25 @@ _Available in Fleet Premium_ } ``` -### Get team +### Get fleet _Available in Fleet Premium_ -`GET /api/v1/fleet/teams/:id` +`GET /api/v1/fleet/fleets/:id` `mdm.macos_settings.custom_settings`, `mdm.windows_settings.custom_settings`, `scripts`, and `mdm.macos_setup` only include the configuration profiles, scripts, and setup experience settings applied using [Fleet's YAML](https://fleetdm.com/docs/configuration/yaml-files). To list profiles, scripts, or setup experience settings added in the UI or API, use the [List configuration profiles](https://fleetdm.com/docs/rest-api/rest-api#list-custom-os-settings-configuration-profiles), [List scripts](https://fleetdm.com/docs/rest-api/rest-api#list-scripts), or GET endpoints from [Setup experience](https://fleetdm.com/docs/rest-api/rest-api#setup-experience) instead. -"No team" will only return `id`, `name`, `webhook_settings.failing_policies_webhook`, `integrations.jira`, and `integrations.zendesk` fields. +"Unassigned" (id 0) will only return `id`, `name`, `webhook_settings.failing_policies_webhook`, `integrations.jira`, and `integrations.zendesk` fields. #### Parameters | Name | Type | In | Description | |------|---------|------|----------------------------------------------------------------------------------------------| -| id | integer | path | **Required.** The desired team's ID. Use `0` for "No team" (hosts not assigned to any team). | +| id | integer | path | **Required.** The desired fleet's ID. Use `0` for "Unassigned" hosts. | #### Example -`GET /api/v1/fleet/teams/1` +`GET /api/v1/fleet/fleets/1` ##### Default response @@ -11996,21 +12164,21 @@ _Available in Fleet Premium_ } ``` -### Create team +### Create fleet _Available in Fleet Premium_ -`POST /api/v1/fleet/teams` +`POST /api/v1/fleet/fleets` #### Parameters | Name | Type | In | Description | | ---- | ------ | ---- | ------------------------------ | -| name | string | body | **Required.** The team's name. | +| name | string | body | **Required.** The fleet's name. | #### Example -`POST /api/v1/fleet/teams` +`POST /api/v1/fleet/fleets` ##### Request body @@ -12064,28 +12232,28 @@ _Available in Fleet Premium_ } ``` -### Update team +### Update fleet _Available in Fleet Premium_ -`PATCH /api/v1/fleet/teams/:id` +`PATCH /api/v1/fleet/fleets/:id` #### Parameters | Name | Type | In | Description | | ------------------------------------------------------- | ------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| id | integer | path | **Required.** The desired team's ID. Use `0` for "No team" (hosts not assigned to any team). **Note:** When using `id=0`, only `webhook_settings.failing_policies_webhook`, `integrations.jira`, and `integrations.zendesk` fields are supported in the request body. | -| name | string | body | The team's name. | -| host_ids | array | body | A list of hosts that belong to the team. | -| user_ids | array | body | A list of users on the team. | -| webhook_settings | object | body | Webhook settings for the team. See [webhook_settings](#webhook-settings2). | -| integrations | object | body | Integrations settings for the team. See [integrations](#integrations3) for details. Note that integrations referenced here must already exist globally, created by a call to [Modify configuration](#modify-configuration). | -| mdm | object | body | MDM settings for the team. See [mdm](#mdm2) for details. | -| host_expiry_settings | object | body | Host expiry settings for the team. See [host_expiry_settings](#host-expiry-settings2) for details. | +| id | integer | path | **Required.** The desired fleet's ID. Use `0` for "Unassigned" hosts. **Note:** When using `id=0`, only `webhook_settings.failing_policies_webhook`, `integrations.jira`, and `integrations.zendesk` fields are supported in the request body. | +| name | string | body | The fleet's name. | +| host_ids | array | body | A list of hosts that belong to the fleet. | +| user_ids | array | body | A list of users on the fleet. | +| webhook_settings | object | body | Webhook settings for the fleet. See [webhook_settings](#webhook-settings2). | +| integrations | object | body | Integrations settings for the fleet. See [integrations](#integrations3) for details. Note that integrations referenced here must already exist globally, created by a call to [Modify configuration](#modify-configuration). | +| mdm | object | body | MDM settings for the fleet. See [mdm](#mdm2) for details. | +| host_expiry_settings | object | body | Host expiry settings for the fleet. See [host_expiry_settings](#host-expiry-settings2) for details. | -#### Example (transfer hosts to a team) +#### Example (transfer hosts to a fleet) -`PATCH /api/v1/fleet/teams/1` +`PATCH /api/v1/fleet/fleets/1` ##### Request body @@ -12237,7 +12405,7 @@ _Available in Fleet Premium_ | Name | Type | Description | | ------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| enable_calendar_events | boolean | Whether or not calendar events are enabled for this team. | +| enable_calendar_events | boolean | Whether or not calendar events are enabled for this fleet. | | webhook_url | string | The URL to send a request to during calendar events, to trigger auto-remediation. | ##### Example request body @@ -12288,8 +12456,8 @@ _Available in Fleet Premium_ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| minimum_version | string | Hosts that belong to this team and are enrolled into Fleet's MDM will be prompted to update when their OS is below this version. | -| deadline | string | Hosts that belong to this team and are enrolled into Fleet's MDM will be forced to update their OS after this deadline (noon local time for hosts already on macOS 14 or above, 20:00 UTC for hosts on earlier macOS versions). | +| minimum_version | string | Hosts that belong to this fleet and have MDM turned on will be prompted to update when their OS is below this version. | +| deadline | string | Hosts that belong to this fleet and have MDM turned on will be forced to update their OS after this deadline (7PM local time for hosts already on macOS 14 or above, 20:00 UTC for hosts on earlier macOS versions). | | update_new_hosts | string | macOS hosts that automatically enroll (ADE) are updated to [Apple's latest version](https://fleetdm.com/guides/enforce-os-updates) during macOS Setup Assistant. For backwards compatibility, if not specified, and `deadline` and `minimum_version` are set, `update_new_hosts` is set to `true`. Otherwise, `update_new_hosts` defaults to `false`. |
@@ -12300,8 +12468,8 @@ _Available in Fleet Premium_ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| minimum_version | string | Hosts that belong to this team will be prompted to update when their OS is below this version. | -| deadline | string | Hosts that belong to this team will be forced to update their OS after this deadline (noon local time). | +| minimum_version | string | Hosts that belong to this fleet will be prompted to update when their OS is below this version. | +| deadline | string | Hosts that belong to this fleet will be forced to update their OS after this deadline (7PM local time). |
@@ -12312,8 +12480,8 @@ _Available in Fleet Premium_ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| minimum_version | string | Hosts that belong to this team will be prompted to update when their OS is below this version. | -| deadline | string | Hosts that belong to this team will be forced to update their OS after this deadline (noon local time). | +| minimum_version | string | Hosts that belong to this fleet will be prompted to update when their OS is below this version. | +| deadline | string | Hosts that belong to this fleet will be forced to update their OS after this deadline (7PM local time). |
@@ -12324,8 +12492,8 @@ _Available in Fleet Premium_ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| deadline_days | integer | Hosts that belong to this team and are enrolled into Fleet's MDM will have this number of days before updates are installed on Windows. | -| grace_period_days | integer | Hosts that belong to this team and are enrolled into Fleet's MDM will have this number of days before Windows restarts to install updates. | +| deadline_days | integer | Hosts that belong to this fleet and have MDM turned on will have this number of days before updates are installed on Windows. | +| grace_period_days | integer | Hosts that belong to this fleet and have MDM turned on will have this number of days before Windows restarts to install updates. |
@@ -12336,7 +12504,7 @@ _Available in Fleet Premium_ | Name | Type | Description | | --------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| enable_disk_encryption | boolean | Hosts that belong to this team will have disk encryption enabled if set to true. | +| enable_disk_encryption | boolean | Hosts that belong to this fleet will have disk encryption enabled if set to true. | | custom_settings | array | Only intended to be used by [Fleet's YAML](https://fleetdm.com/docs/configuration/yaml-files). To add macOS configuration profiles using Fleet's API, use the [Create custom OS setting (configuration profile)](#create-custom-os-setting-configuration-profile) endpoint instead. |
@@ -12430,24 +12598,24 @@ _Available in Fleet Premium_ } ``` -### Add users to team +### Add users to fleet _Available in Fleet Premium_ -`PATCH /api/v1/fleet/teams/:id/users` +`PATCH /api/v1/fleet/fleets/:id/users` #### Parameters | Name | Type | In | Description | |------------------|---------|------|----------------------------------------------| -| id | integer | path | **Required.** The desired team's ID. | +| id | integer | path | **Required.** The desired fleet's ID. | | users | string | body | Array of users to add. | |   id | integer | body | The id of the user. | -|   role | string | body | The team role that the user will be granted. Options are: "admin", "maintainer", "observer", "observer_plus", and "gitops". | +|   role | string | body | The fleet role that the user will be granted. Options are: "admin", "maintainer", "observer", "observer_plus", and "gitops". | #### Example -`PATCH /api/v1/fleet/teams/1/users` +`PATCH /api/v1/fleet/fleets/1/users` ##### Request body @@ -12574,24 +12742,24 @@ _Available in Fleet Premium_ } ``` -### Update team's agent options +### Update fleet's agent options _Available in Fleet Premium_ -`POST /api/v1/fleet/teams/:id/agent_options` +`POST /api/v1/fleet/fleets/:id/agent_options` #### Parameters | Name | Type | In | Description | | --- | --- | --- | --- | -| id | integer | path | **Required.** The desired team's ID. | +| id | integer | path | **Required.** The desired fleet's ID. | | force | boolean | query | Force apply the options even if there are validation errors. | | dry_run | boolean | query | Validate the options and return any validation errors, but do not apply the changes. | -| _JSON data_ | object | body | The JSON to use as agent options for this team. See [Agent options](https://fleetdm.com/docs/using-fleet/configuration-files#agent-options) for details. | +| _JSON data_ | object | body | The JSON to use as agent options for this fleet. See [Agent options](https://fleetdm.com/docs/using-fleet/configuration-files#agent-options) for details. | #### Example -`POST /api/v1/fleet/teams/1/agent_options` +`POST /api/v1/fleet/fleets/1/agent_options` ##### Request body @@ -12663,21 +12831,21 @@ _Available in Fleet Premium_ } ``` -### Delete team +### Delete fleet _Available in Fleet Premium_ -`DELETE /api/v1/fleet/teams/:id` +`DELETE /api/v1/fleet/fleets/:id` #### Parameters | Name | Type | In | Description | | ---- | ------ | ---- | ------------------------------------ | -| id | integer | path | **Required.** The desired team's ID. | +| id | integer | path | **Required.** The desired fleet's ID. | #### Example -`DELETE /api/v1/fleet/teams/1` +`DELETE /api/v1/fleet/fleets/1` #### Default response @@ -12691,7 +12859,7 @@ _Available in Fleet Premium_ ### Translate IDs -Transforms a host name into a host id. For example, the Fleet UI use this endpoint when sending live queries to a set of hosts. +Transforms a host name into a host id. For example, the Fleet UI uses this endpoint when sending live reports to a set of hosts. `POST /api/v1/fleet/translate` @@ -12813,7 +12981,7 @@ Returns a list of all enabled users | page | integer | query | Page number of the results to fetch. | | query | string | query | Search query keywords. Searchable fields include `name` and `email`. | | per_page | integer | query | Results per page. | -| team_id | integer | query | _Available in Fleet Premium_. Filters the users to only include users in the specified team. | +| fleet_id | integer | query | _Available in Fleet Premium_. Filters the users to only include users in the specified fleet. | #### Example @@ -12842,7 +13010,7 @@ None. "mfa_enabled": false, "global_role": null, "api_only": false, - "teams": [ + "fleets": [ { "id": 1, "created_at": "0001-01-01T00:00:00Z", @@ -12889,9 +13057,9 @@ By default, the user will be forced to reset its password upon first login. | sso_enabled | boolean | body | Whether or not SSO is enabled for the user. | | mfa_enabled | boolean | body | _Available in Fleet Premium._ Whether or not the user must click a magic link emailed to them to log in, after they successfully enter their username and password. Incompatible with SSO and API-only users. | | api_only | boolean | body | User is an "API-only" user (cannot use web UI) if true. | -| global_role | string | body | The role assigned to the user. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). In Fleet 4.30.0 and 4.31.0, the `observer_plus` and `gitops` roles were introduced respectively. If `global_role` is specified, `teams` cannot be specified. For more information, see [manage access](https://fleetdm.com/docs/using-fleet/manage-access). | +| global_role | string | body | The role assigned to the user. If `global_role` is specified, `fleets` cannot be specified. For more information, see [manage access](https://fleetdm.com/docs/using-fleet/manage-access). | | admin_forced_password_reset | boolean | body | Sets whether the user will be forced to reset its password upon first login (default=true) | -| teams | array | body | _Available in Fleet Premium_. The teams and respective roles assigned to the user. Should contain an array of objects in which each object includes the team's `id` and the user's `role` on each team. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). In Fleet 4.30.0 and 4.31.0, the `observer_plus` and `gitops` roles were introduced respectively. If `teams` is specified, `global_role` cannot be specified. For more information, see [manage access](https://fleetdm.com/docs/using-fleet/manage-access). | +| fleets | array | body | _Available in Fleet Premium_. The fleets and respective roles assigned to the user. Should contain an array of objects in which each object includes the fleet's `id` and the user's `role` on each fleet. If `fleets` is specified, `global_role` cannot be specified. For more information, see [manage access](https://fleetdm.com/docs/using-fleet/manage-access). | #### Example @@ -12905,7 +13073,7 @@ By default, the user will be forced to reset its password upon first login. "email": "janedoe@example.com", "password": "test-123", "api_only": true, - "teams": [ + "fleets": [ { "id": 2, "role": "observer" @@ -12937,7 +13105,7 @@ By default, the user will be forced to reset its password upon first login. "mfa_enabled": false, "api_only": true, "global_role": null, - "teams": [ + "fleets": [ { "id": 2, "role": "observer" @@ -13020,7 +13188,7 @@ Creates a user account after an invited user provides registration information a "sso_enabled": false, "mfa_enabled": false, "global_role": "admin", - "teams": [] + "fleets": [] } } ``` @@ -13110,7 +13278,7 @@ Returns all information about a specific user. "mfa_enabled": false, "global_role": "admin", "api_only": false, - "teams": [] + "fleets": [] } } ``` @@ -13148,8 +13316,8 @@ Returns all information about a specific user. | api_only | boolean | body | User is an "API-only" user (cannot use web UI) if true. | | password | string | body | The user's current password, required to change the user's own email or password (not required for an admin to modify another user). | | new_password| string | body | The user's new password. | -| global_role | string | body | The role assigned to the user. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). If `global_role` is specified, `teams` cannot be specified. | -| teams | array | body | _Available in Fleet Premium_. The teams and respective roles assigned to the user. Should contain an array of objects in which each object includes the team's `id` and the user's `role` on each team. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). If `teams` is specified, `global_role` cannot be specified. | +| global_role | string | body | The role assigned to the user. If `global_role` is specified, `fleets` cannot be specified. | +| fleets | array | body | _Available in Fleet Premium_. The fleets and respective roles assigned to the user. Should contain an array of objects in which each object includes the fleet's `id` and the user's `role` on each fleet. In Fleet 4.0.0, 3 user roles were introduced (`admin`, `maintainer`, and `observer`). If `fleets` is specified, `global_role` cannot be specified. | #### Example @@ -13182,12 +13350,12 @@ Returns all information about a specific user. "sso_enabled": false, "mfa_enabled": false, "api_only": false, - "teams": [] + "fleets": [] } } ``` -#### Example (modify a user's teams) +#### Example (modify a user's fleets) `PATCH /api/v1/fleet/users/2` @@ -13195,7 +13363,7 @@ Returns all information about a specific user. ```json { - "teams": [ + "fleets": [ { "id": 1, "role": "observer" @@ -13226,7 +13394,7 @@ Returns all information about a specific user. "sso_enabled": false, "mfa_enabled": false, "global_role": "admin", - "teams": [ + "fleets": [ { "id": 2, "role": "observer" @@ -13303,7 +13471,7 @@ The selected user is logged out of Fleet and required to reset their password du "mfa_enabled": false, "sso_enabled": false, "global_role": "observer", - "teams": [] + "fleets": [] } } ``` @@ -13376,12 +13544,12 @@ Deletes the selected user's sessions in Fleet. Also deletes the user's API token | Name | Type | In | Description | | ----------- | ------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| global_role | string | body | Role the user will be granted. Either a global role is needed, or a team role. | +| global_role | string | body | Role the user will be granted. Either a global role is needed, or a fleet role. | | email | string | body | **Required.** The email of the invited user. This email will receive the invitation link. | | name | string | body | **Required.** The name of the invited user. | | sso_enabled | boolean | body | **Required.** Whether or not SSO will be enabled for the invited user. | | mfa_enabled | boolean | body | _Available in Fleet Premium._ Whether or not the invited user must click a magic link emailed to them to log in, after they successfully enter their username and password. Users can have SSO or MFA enabled, but not both. | -| teams | array | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | +| fleets | array | body | _Available in Fleet Premium_. A list of the fleets the user is a member of. Each item includes the fleet's ID and the user's role in the specified fleet. | #### Example @@ -13396,7 +13564,7 @@ Deletes the selected user's sessions in Fleet. Also deletes the user's API token "sso_enabled": false, "mfa_enabled": false, "global_role": null, - "teams": [ + "fleets": [ { "id": 2, "role": "observer" @@ -13424,7 +13592,7 @@ Deletes the selected user's sessions in Fleet. Also deletes the user's API token "name": "John", "sso_enabled": false, "mfa_enabled": false, - "teams": [ + "fleets": [ { "id": 10, "created_at": "0001-01-01T00:00:00Z", @@ -13484,7 +13652,7 @@ Returns a list of the active invitations in Fleet. "sso_enabled": false, "mfa_enabled": false, "global_role": "admin", - "teams": [] + "fleets": [] }, { "created_at": "0001-01-01T00:00:00Z", @@ -13495,7 +13663,7 @@ Returns a list of the active invitations in Fleet. "sso_enabled": false, "mfa_enabled": false, "global_role": "admin", - "teams": [] + "fleets": [] } ] } @@ -13553,7 +13721,7 @@ Verify the specified invite. "sso_enabled": false, "mfa_enabled": false, "global_role": "admin", - "teams": [] + "fleets": [] } } ``` @@ -13582,12 +13750,12 @@ Verify the specified invite. | Name | Type | In | Description | | ----------- | ------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| global_role | string | body | Role the user will be granted. Either a global role is needed, or a team role. | +| global_role | string | body | Role the user will be granted. Either a global role is needed, or a fleet role. | | email | string | body | The email of the invited user. Updates on the email won't resend the invitation. | | name | string | body | The name of the invited user. | | sso_enabled | boolean | body | Whether or not SSO will be enabled for the invited user. | | mfa_enabled | boolean | body | _Available in Fleet Premium._ Whether or not the invited user must click a magic link emailed to them to log in, after they successfully enter their username and password. Users can have SSO or MFA enabled, but not both. | -| teams | array | body | _Available in Fleet Premium_. A list of the teams the user is a member of. Each item includes the team's ID and the user's role in the specified team. | +| fleets | array | body | _Available in Fleet Premium_. A list of the fleets the user is a member of. Each item includes the fleet's ID and the user's role in the specified fleet. | #### Example @@ -13602,7 +13770,7 @@ Verify the specified invite. "sso_enabled": false, "mfa_enabled": false, "global_role": null, - "teams": [ + "fleets": [ { "id": 2, "role": "observer" @@ -13630,7 +13798,7 @@ Verify the specified invite. "name": "John", "sso_enabled": false, "mfa_enabled": false, - "teams": [ + "fleets": [ { "id": 10, "created_at": "0001-01-01T00:00:00Z", From 97433a5de6149126122c6c4653afcf4c44983f5e Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Thu, 19 Mar 2026 15:37:22 -0400 Subject: [PATCH 053/141] Update PEM header type per hydrant spec (#42052) **Related issue:** Resolves #40910 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually --- changes/40910-correct-request-certificate-pem | 1 + ee/server/service/request_certificate.go | 7 ++++--- ee/server/service/request_certificate_test.go | 10 +++++----- 3 files changed, 10 insertions(+), 8 deletions(-) create mode 100644 changes/40910-correct-request-certificate-pem diff --git a/changes/40910-correct-request-certificate-pem b/changes/40910-correct-request-certificate-pem new file mode 100644 index 00000000000..73c3e8965da --- /dev/null +++ b/changes/40910-correct-request-certificate-pem @@ -0,0 +1 @@ +* Updated the Request Certificate API to return the proper PEM header for PKCS #7 certificates returned by EST CAs diff --git a/ee/server/service/request_certificate.go b/ee/server/service/request_certificate.go index 308d629ea2b..14129a3da41 100644 --- a/ee/server/service/request_certificate.go +++ b/ee/server/service/request_certificate.go @@ -16,7 +16,6 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/fleetdm/fleet/v4/server/ptr" ) // This code largely adapted from fleet/website/api/controllers/get-est-device-certificate.js @@ -135,8 +134,10 @@ func (svc *Service) RequestCertificate(ctx context.Context, p fleet.RequestCerti return nil, &fleet.BadRequestError{Message: fmt.Sprintf("EST certificate request failed: %s", err.Error())} } svc.logger.InfoContext(ctx, "Successfully retrieved a certificate from EST", "ca_id", ca.ID, "idp_username", idpUsername) - // Wrap the certificate in a PEM block for easier consumption by the client - return ptr.String("-----BEGIN CERTIFICATE-----\n" + string(certificate.Certificate) + "\n-----END CERTIFICATE-----\n"), nil + // Wrap the certificate in a PEM block for easier consumption by the client. TODO: If we ever + // support CAs other than Hydrant/EST in this API, this may need to be modified to be aware of + // their formats. + return new("-----BEGIN PKCS7-----\n" + string(certificate.Certificate) + "\n-----END PKCS7-----\n"), nil } func (svc *Service) introspectIDPToken(ctx context.Context, idpClientID, idpToken, idpOauthURL string) (*oauthIntrospectionResponse, error) { diff --git a/ee/server/service/request_certificate_test.go b/ee/server/service/request_certificate_test.go index db972ba1ff8..3004e0df55e 100644 --- a/ee/server/service/request_certificate_test.go +++ b/ee/server/service/request_certificate_test.go @@ -191,7 +191,7 @@ func TestRequestCertificate(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, cert) - require.Equal(t, "-----BEGIN CERTIFICATE-----\n"+hydrantSimpleEnrollResponse+"\n-----END CERTIFICATE-----\n", *cert) + require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert) }) t.Run("Request a certificate - Happy path, no IDP", func(t *testing.T) { @@ -203,7 +203,7 @@ func TestRequestCertificate(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, cert) - require.Equal(t, "-----BEGIN CERTIFICATE-----\n"+hydrantSimpleEnrollResponse+"\n-----END CERTIFICATE-----\n", *cert) + require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert) }) t.Run("Request a certificate - Happy path, no IDP, http sig auth", func(t *testing.T) { @@ -223,7 +223,7 @@ func TestRequestCertificate(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, cert) - require.Equal(t, "-----BEGIN CERTIFICATE-----\n"+hydrantSimpleEnrollResponse+"\n-----END CERTIFICATE-----\n", *cert) + require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert) }) t.Run("Request a certificate - Happy path, no IDP, UPN does not match IDP info(should pass)", func(t *testing.T) { @@ -235,7 +235,7 @@ func TestRequestCertificate(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, cert) - require.Equal(t, "-----BEGIN CERTIFICATE-----\n"+hydrantSimpleEnrollResponse+"\n-----END CERTIFICATE-----\n", *cert) + require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert) }) t.Run("Request a certificate - CA returns error", func(t *testing.T) { @@ -310,7 +310,7 @@ func TestRequestCertificate(t *testing.T) { }) require.NoError(t, err) require.NotNil(t, cert) - require.Equal(t, "-----BEGIN CERTIFICATE-----\n"+hydrantSimpleEnrollResponse+"\n-----END CERTIFICATE-----\n", *cert) + require.Equal(t, "-----BEGIN PKCS7-----\n"+hydrantSimpleEnrollResponse+"\n-----END PKCS7-----\n", *cert) }) t.Run("Request certificate - non-Hydrant and non-EST CA", func(t *testing.T) { From b46414ed562c9e6bf9e0466b529cfafcee5d58e2 Mon Sep 17 00:00:00 2001 From: Steven Palmesano <3100993+spalmesano0@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:54:30 -0500 Subject: [PATCH 054/141] Add GlobalProtect profile (#42096) --- .../globalprotect-vpn-settings.mobileconfig | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/solutions/ios-ipados/configuration-profiles/globalprotect-vpn-settings.mobileconfig diff --git a/docs/solutions/ios-ipados/configuration-profiles/globalprotect-vpn-settings.mobileconfig b/docs/solutions/ios-ipados/configuration-profiles/globalprotect-vpn-settings.mobileconfig new file mode 100644 index 00000000000..ff048b027a4 --- /dev/null +++ b/docs/solutions/ios-ipados/configuration-profiles/globalprotect-vpn-settings.mobileconfig @@ -0,0 +1,55 @@ + + + + + PayloadContent + + + VPN + + AuthenticationMethod + Password + RemoteAddress + TODO: REMOTE ADDRESS URL + + Proxies + + VendorConfig + + saml-use-default-browser + true + + VPNType + VPN + VPNSubType + com.paloaltonetworks.globalprotect.vpn + UserDefinedName + GlobalProtect VPN + PayloadDisplayName + GlobalProtect Configuration + PayloadType + com.apple.vpn.managed + PayloadUUID + 02780BA4-96ED-40E1-970F-4DA56764E0C2 + PayloadIdentifier + com.fleetdm.ios.globalprotect.vpn + PayloadVersion + 1 + + + PayloadDisplayName + GlobalProtect VPN Settings + PayloadType + Configuration + PayloadUUID + 19C9E483-B3C6-4387-BC8B-9CCFC7E385DD + PayloadIdentifier + com.fleetdm.ios.globalprotect.vpn + PayloadVersion + 1 + PayloadRemovalDisallowed + + PayloadScope + System + + From a8c9e261d73c3b7cd0e23fd9596efbf62d4c990c Mon Sep 17 00:00:00 2001 From: Magnus Jensen Date: Thu, 19 Mar 2026 14:58:10 -0500 Subject: [PATCH 055/141] speed up macOS profile delivery for initial enrollments (#41960) **Related issue:** Resolves #34433 It speeds up the cron, meaning fleetd, bootstrap and now profiles should be sent within 10 seconds of being known to fleet, compared to the previous 1 minute. It's heavily based on my last PR, so the structure and changes are close to identical, with some small differences. **I did not do the redis key part in this PR, as I think that should come in it's own PR, to avoid overlooking logic bugs with that code, and since this one is already quite sized since we're moving core pieces of code around.** # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** * Faster macOS onboarding: device profiles are delivered and installed as part of DEP enrollment, shortening initial setup. * Improved profile handling: per-host profile preprocessing, secret detection, and clearer failure marking. * **Improvements** * Consolidated SCEP/NDES error messaging for clearer diagnostics. * Cron/work scheduling tuned to prioritize Apple MDM profile delivery. * **Tests** * Expanded MDM unit and integration tests, including DeclarativeManagement handling. --- changes/34433-speedup-macos-profile-delivery | 1 + cmd/fleet/cron.go | 59 +- cmd/fleet/serve.go | 14 +- .../gitops_enterprise_integration_test.go | 4 +- ee/server/service/certificate_authorities.go | 9 +- .../service/certificate_authorities_test.go | 13 +- ee/server/service/errors.go | 36 - ee/server/service/{ => scep}/scep_proxy.go | 57 +- .../service/{ => scep}/scep_proxy_test.go | 2 +- .../testdata/mscep_admin_cache_full.html | 0 .../mscep_admin_insufficient_permissions.html | 0 .../testdata/mscep_admin_password.html | 0 .../service/{ => scep}/testdata/testca/ca.key | 0 .../service/{ => scep}/testdata/testca/ca.pem | 0 ee/server/service/{ => scep}/testing_utils.go | 2 +- server/fleet/apple_profiles.go | 94 ++ server/fleet/cron_schedules.go | 1 + server/mdm/apple/profile_processor.go | 834 +++++++++++++ server/mdm/apple/profile_processor_test.go | 999 +++++++++++++++ server/ptr/ptr.go | 8 +- server/service/apple_mdm.go | 949 +-------------- server/service/apple_mdm_test.go | 1083 +---------------- server/service/handler.go | 4 +- ...ntegration_certificate_authorities_test.go | 8 +- server/service/integration_mdm_dep_test.go | 61 +- .../service/integration_mdm_lifecycle_test.go | 7 + .../integration_mdm_release_worker_test.go | 102 +- .../integration_mdm_setup_experience_test.go | 59 + server/service/integration_mdm_test.go | 26 +- server/service/microsoft_mdm.go | 25 +- server/service/testing_utils.go | 5 +- server/worker/apple_mdm.go | 120 ++ server/worker/apple_mdm_test.go | 54 + 33 files changed, 2508 insertions(+), 2128 deletions(-) create mode 100644 changes/34433-speedup-macos-profile-delivery rename ee/server/service/{ => scep}/scep_proxy.go (92%) rename ee/server/service/{ => scep}/scep_proxy_test.go (99%) rename ee/server/service/{ => scep}/testdata/mscep_admin_cache_full.html (100%) rename ee/server/service/{ => scep}/testdata/mscep_admin_insufficient_permissions.html (100%) rename ee/server/service/{ => scep}/testdata/mscep_admin_password.html (100%) rename ee/server/service/{ => scep}/testdata/testca/ca.key (100%) rename ee/server/service/{ => scep}/testdata/testca/ca.pem (100%) rename ee/server/service/{ => scep}/testing_utils.go (99%) create mode 100644 server/fleet/apple_profiles.go create mode 100644 server/mdm/apple/profile_processor.go create mode 100644 server/mdm/apple/profile_processor_test.go diff --git a/changes/34433-speedup-macos-profile-delivery b/changes/34433-speedup-macos-profile-delivery new file mode 100644 index 00000000000..b322b8ed9d6 --- /dev/null +++ b/changes/34433-speedup-macos-profile-delivery @@ -0,0 +1 @@ +- Moved Apple MDM worker to a faster cron, and started sending profiles on Post DEP enrollment job, to speed up initial macOS setup. \ No newline at end of file diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index edc4c7a0dcd..9c94ed87912 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -732,8 +732,6 @@ func newWorkerIntegrationsSchedule( logger *slog.Logger, depStorage *mysql.NanoDEPStorage, commander *apple_mdm.MDMAppleCommander, - bootstrapPackageStore fleet.MDMBootstrapPackageStore, - vppInstaller fleet.AppleMDMVPPInstaller, androidModule android.Service, ) (*schedule.Schedule, error) { const ( @@ -782,13 +780,7 @@ func newWorkerIntegrationsSchedule( DEPService: depSvc, DEPClient: depCli, } - appleMDM := &worker.AppleMDM{ - Datastore: ds, - Log: logger, - Commander: commander, - BootstrapPackageStore: bootstrapPackageStore, - VPPInstaller: vppInstaller, - } + vppVerify := &worker.AppleSoftware{ Datastore: ds, Log: logger, @@ -803,7 +795,7 @@ func newWorkerIntegrationsSchedule( Log: logger, AndroidModule: androidModule, } - w.Register(jira, zendesk, macosSetupAsst, appleMDM, dbMigrate, vppVerify, softwareWorker) + w.Register(jira, zendesk, macosSetupAsst, dbMigrate, vppVerify, softwareWorker) // Read app config a first time before starting, to clear up any failer client // configuration if we're not on a fleet-owned server. Technically, the ServerURL @@ -904,6 +896,53 @@ func newFailerClient(forcedFailures string) *worker.TestAutomationFailer { return failerClient } +func newAppleMDMWorkerSchedule( + ctx context.Context, + instanceID string, + ds fleet.Datastore, + logger *slog.Logger, + commander *apple_mdm.MDMAppleCommander, + bootstrapPackageStore fleet.MDMBootstrapPackageStore, + vppInstaller fleet.AppleMDMVPPInstaller, +) (*schedule.Schedule, error) { + const ( + name = string(fleet.CronAppleMDMWorker) + scheduleInterval = 10 * time.Second // schedule a worker to run every 10 seconds if none is running + maxRunTime = 10 * time.Minute // allow the worker to run for 10 minutes + ) + + logger = logger.With("cron", name) + + w := worker.NewWorker(ds, logger) + + appleMDM := &worker.AppleMDM{ + Datastore: ds, + Log: logger, + Commander: commander, + BootstrapPackageStore: bootstrapPackageStore, + VPPInstaller: vppInstaller, + } + + w.Register(appleMDM) + + s := schedule.New( + ctx, name, instanceID, scheduleInterval, ds, ds, + schedule.WithAltLockID("apple_mdm"), + schedule.WithLogger(logger), + schedule.WithJob("apple_mdm_worker", func(ctx context.Context) error { + workCtx, cancel := context.WithTimeout(ctx, maxRunTime) + defer cancel() + + if err := w.ProcessJobs(workCtx); err != nil { + return fmt.Errorf("processing apple mdm jobs: %w", err) + } + return nil + }), + ) + + return s, nil +} + func newCleanupsAndAggregationSchedule( ctx context.Context, instanceID string, diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 07f3c7cbc27..1ac08456e3a 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -30,6 +30,7 @@ import ( "github.com/fleetdm/fleet/v4/ee/server/service/est" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/pkg/str" @@ -872,7 +873,7 @@ the way that the Fleet server works. } eh := errorstore.NewHandler(ctx, redisPool, logger, config.Logging.ErrorRetentionPeriod) - scepConfigMgr := eeservice.NewSCEPConfigService(logger, nil) + scepConfigMgr := scep.NewSCEPConfigService(logger, nil) digiCertService := digicert.NewService(digicert.WithLogger(logger)) ctx = ctxerr.NewContext(ctx, eh) @@ -1176,12 +1177,19 @@ the way that the Fleet server works. if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) { commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService) - vppInstaller := svc.(fleet.AppleMDMVPPInstaller) - return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander, bootstrapPackageStore, vppInstaller, androidSvc) + return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander, androidSvc) }); err != nil { initFatal(err, "failed to register worker integrations schedule") } + if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) { + commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService) + vppInstaller := svc.(fleet.AppleMDMVPPInstaller) + return newAppleMDMWorkerSchedule(ctx, instanceID, ds, logger, commander, bootstrapPackageStore, vppInstaller) + }); err != nil { + initFatal(err, "failed to register apple_mdm_worker schedule") + } + if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) { return newAppleMDMDEPProfileAssigner(ctx, instanceID, config.MDM.AppleDEPSyncPeriodicity, ds, depStorage, logger) }); err != nil { diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go index 5471c3c5dbe..9d8c9a90cb7 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go @@ -22,8 +22,8 @@ import ( "github.com/fleetdm/fleet/v4/cmd/fleetctl/fleetctl/testing_utils" "github.com/fleetdm/fleet/v4/cmd/fleetctl/integrationtest" ma "github.com/fleetdm/fleet/v4/ee/maintained-apps" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" "github.com/fleetdm/fleet/v4/ee/server/service/digicert" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/datastore/filesystem" "github.com/fleetdm/fleet/v4/server/datastore/mysql" @@ -111,7 +111,7 @@ func (s *enterpriseIntegrationGitopsTestSuite) SetupSuite() { SCEPStorage: scepStorage, Pool: redisPool, APNSTopic: "com.apple.mgmt.External.10ac3ce5-4668-4e58-b69a-b2b5ce667589", - SCEPConfigService: eeservice.NewSCEPConfigService(slog.New(slog.NewTextHandler(os.Stdout, nil)), nil), + SCEPConfigService: scep.NewSCEPConfigService(slog.New(slog.NewTextHandler(os.Stdout, nil)), nil), DigiCertService: digicert.NewService(), SoftwareTitleIconStore: softwareTitleIconStore, } diff --git a/ee/server/service/certificate_authorities.go b/ee/server/service/certificate_authorities.go index f67a2cc3d66..0620511a694 100644 --- a/ee/server/service/certificate_authorities.go +++ b/ee/server/service/certificate_authorities.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -380,9 +381,9 @@ func (svc *Service) validateNDESSCEPProxy(ctx context.Context, ndesSCEP *fleet.N if err := svc.scepConfigService.ValidateNDESSCEPAdminURL(ctx, *ndesSCEP); err != nil { svc.logger.ErrorContext(ctx, "Failed to validate NDES SCEP admin URL", "err", err) switch { - case errors.As(err, &NDESPasswordCacheFullError{}): + case errors.As(err, &scep.NDESPasswordCacheFullError{}): return &fleet.BadRequestError{Message: fmt.Sprintf("%sThe NDES password cache is full. Please increase the number of cached passwords in NDES and try again.", errPrefix)} - case errors.As(err, &NDESInsufficientPermissionsError{}): + case errors.As(err, &scep.NDESInsufficientPermissionsError{}): return &fleet.BadRequestError{Message: fmt.Sprintf("%sInsufficient permissions for NDES SCEP admin URL. Please correct and try again.", errPrefix)} default: return &fleet.BadRequestError{Message: fmt.Sprintf("%sInvalid NDES SCEP admin URL or credentials. Please correct and try again.", errPrefix)} @@ -1441,9 +1442,9 @@ func (svc *Service) validateNDESSCEPProxyUpdate(ctx context.Context, ndesSCEP *f if err := svc.scepConfigService.ValidateNDESSCEPAdminURL(ctx, NDESProxy); err != nil { svc.logger.ErrorContext(ctx, "Failed to validate NDES SCEP admin URL", "err", err) switch { - case errors.As(err, &NDESPasswordCacheFullError{}): + case errors.As(err, &scep.NDESPasswordCacheFullError{}): return &fleet.BadRequestError{Message: fmt.Sprintf("%sThe NDES password cache is full. Please increase the number of cached passwords in NDES and try again.", errPrefix)} - case errors.As(err, &NDESInsufficientPermissionsError{}): + case errors.As(err, &scep.NDESInsufficientPermissionsError{}): return &fleet.BadRequestError{Message: fmt.Sprintf("%sInsufficient permissions for NDES SCEP admin URL. Please correct and try again.", errPrefix)} default: return &fleet.BadRequestError{Message: fmt.Sprintf("%sInvalid NDES SCEP admin URL or credentials. Please correct and try again.", errPrefix)} diff --git a/ee/server/service/certificate_authorities_test.go b/ee/server/service/certificate_authorities_test.go index 1c3a4d2902b..26eed78352c 100644 --- a/ee/server/service/certificate_authorities_test.go +++ b/ee/server/service/certificate_authorities_test.go @@ -14,6 +14,7 @@ import ( "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/ee/server/service/est" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -877,7 +878,7 @@ func TestCreatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil }, ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESInvalidError("some error") + return scep.NewNDESInvalidError("some error") }, } @@ -902,7 +903,7 @@ func TestCreatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil }, ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESPasswordCacheFullError("mock error") + return scep.NewNDESPasswordCacheFullError("mock error") }, } @@ -927,7 +928,7 @@ func TestCreatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateSCEPURLFunc: func(_ context.Context, _ string) error { return nil }, ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESInsufficientPermissionsError("mock error") + return scep.NewNDESInsufficientPermissionsError("mock error") }, } @@ -1710,7 +1711,7 @@ func TestUpdatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESInvalidError("some error") + return scep.NewNDESInvalidError("some error") }, } @@ -1730,7 +1731,7 @@ func TestUpdatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESPasswordCacheFullError("some error") + return scep.NewNDESPasswordCacheFullError("some error") }, } @@ -1750,7 +1751,7 @@ func TestUpdatingCertificateAuthorities(t *testing.T) { svc.scepConfigService = &scep_mock.SCEPConfigService{ ValidateNDESSCEPAdminURLFunc: func(_ context.Context, _ fleet.NDESSCEPProxyCA) error { - return NewNDESInsufficientPermissionsError("some error") + return scep.NewNDESInsufficientPermissionsError("some error") }, } diff --git a/ee/server/service/errors.go b/ee/server/service/errors.go index 7885f6df8e4..da40f5e0df7 100644 --- a/ee/server/service/errors.go +++ b/ee/server/service/errors.go @@ -20,42 +20,6 @@ func (e *notFoundError) IsNotFound() bool { return true } -type NDESInvalidError struct { - msg string -} - -func (e NDESInvalidError) Error() string { - return e.msg -} - -func NewNDESInvalidError(msg string) NDESInvalidError { - return NDESInvalidError{msg: msg} -} - -type NDESPasswordCacheFullError struct { - msg string -} - -func (e NDESPasswordCacheFullError) Error() string { - return e.msg -} - -func NewNDESPasswordCacheFullError(msg string) NDESPasswordCacheFullError { - return NDESPasswordCacheFullError{msg: msg} -} - -type NDESInsufficientPermissionsError struct { - msg string -} - -func (e NDESInsufficientPermissionsError) Error() string { - return e.msg -} - -func NewNDESInsufficientPermissionsError(msg string) NDESInsufficientPermissionsError { - return NDESInsufficientPermissionsError{msg: msg} -} - type InvalidIDPTokenError struct{} func (e InvalidIDPTokenError) Error() string { diff --git a/ee/server/service/scep_proxy.go b/ee/server/service/scep/scep_proxy.go similarity index 92% rename from ee/server/service/scep_proxy.go rename to ee/server/service/scep/scep_proxy.go index 29fafd3861d..35a1545ec21 100644 --- a/ee/server/service/scep_proxy.go +++ b/ee/server/service/scep/scep_proxy.go @@ -1,4 +1,4 @@ -package service +package scep import ( "bytes" @@ -636,3 +636,58 @@ func (s *SCEPConfigService) GetSmallstepSCEPChallenge(ctx context.Context, ca fl return string(b), nil } + +type NDESInvalidError struct { + msg string +} + +func (e NDESInvalidError) Error() string { + return e.msg +} + +func NewNDESInvalidError(msg string) NDESInvalidError { + return NDESInvalidError{msg: msg} +} + +type NDESPasswordCacheFullError struct { + msg string +} + +func (e NDESPasswordCacheFullError) Error() string { + return e.msg +} + +func NewNDESPasswordCacheFullError(msg string) NDESPasswordCacheFullError { + return NDESPasswordCacheFullError{msg: msg} +} + +type NDESInsufficientPermissionsError struct { + msg string +} + +func (e NDESInsufficientPermissionsError) Error() string { + return e.msg +} + +func NewNDESInsufficientPermissionsError(msg string) NDESInsufficientPermissionsError { + return NDESInsufficientPermissionsError{msg: msg} +} + +// NDESChallengeErrorToDetail translates NDES-specific error types into user-friendly messages +// for profile failure details. Used by both Apple and Windows NDES profile processing. +func NDESChallengeErrorToDetail(err error) string { + varName := fleet.FleetVarNDESSCEPChallenge.WithPrefix() + switch { + case errors.As(err, &NDESInvalidError{}): + return fmt.Sprintf("Invalid NDES admin credentials. Fleet couldn't populate %s. "+ + "Please update credentials in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol.", varName) + case errors.As(err, &NDESPasswordCacheFullError{}): + return fmt.Sprintf("The NDES password cache is full. Fleet couldn't populate %s. "+ + "Please increase the number of cached passwords in NDES and try again.", varName) + case errors.As(err, &NDESInsufficientPermissionsError{}): + return fmt.Sprintf("This account does not have sufficient permissions to enroll with SCEP. Fleet couldn't populate %s. "+ + "Please update the account with NDES SCEP enroll permissions and try again.", varName) + default: + return fmt.Sprintf("Fleet couldn't populate %s. %s", varName, err.Error()) + } +} diff --git a/ee/server/service/scep_proxy_test.go b/ee/server/service/scep/scep_proxy_test.go similarity index 99% rename from ee/server/service/scep_proxy_test.go rename to ee/server/service/scep/scep_proxy_test.go index 6ce2517e57c..e83ddb22c0e 100644 --- a/ee/server/service/scep_proxy_test.go +++ b/ee/server/service/scep/scep_proxy_test.go @@ -1,4 +1,4 @@ -package service +package scep import ( "context" diff --git a/ee/server/service/testdata/mscep_admin_cache_full.html b/ee/server/service/scep/testdata/mscep_admin_cache_full.html similarity index 100% rename from ee/server/service/testdata/mscep_admin_cache_full.html rename to ee/server/service/scep/testdata/mscep_admin_cache_full.html diff --git a/ee/server/service/testdata/mscep_admin_insufficient_permissions.html b/ee/server/service/scep/testdata/mscep_admin_insufficient_permissions.html similarity index 100% rename from ee/server/service/testdata/mscep_admin_insufficient_permissions.html rename to ee/server/service/scep/testdata/mscep_admin_insufficient_permissions.html diff --git a/ee/server/service/testdata/mscep_admin_password.html b/ee/server/service/scep/testdata/mscep_admin_password.html similarity index 100% rename from ee/server/service/testdata/mscep_admin_password.html rename to ee/server/service/scep/testdata/mscep_admin_password.html diff --git a/ee/server/service/testdata/testca/ca.key b/ee/server/service/scep/testdata/testca/ca.key similarity index 100% rename from ee/server/service/testdata/testca/ca.key rename to ee/server/service/scep/testdata/testca/ca.key diff --git a/ee/server/service/testdata/testca/ca.pem b/ee/server/service/scep/testdata/testca/ca.pem similarity index 100% rename from ee/server/service/testdata/testca/ca.pem rename to ee/server/service/scep/testdata/testca/ca.pem diff --git a/ee/server/service/testing_utils.go b/ee/server/service/scep/testing_utils.go similarity index 99% rename from ee/server/service/testing_utils.go rename to ee/server/service/scep/testing_utils.go index 6395a35d0f3..2bfbaee980b 100644 --- a/ee/server/service/testing_utils.go +++ b/ee/server/service/scep/testing_utils.go @@ -1,4 +1,4 @@ -package service +package scep import ( "crypto/x509" diff --git a/server/fleet/apple_profiles.go b/server/fleet/apple_profiles.go new file mode 100644 index 00000000000..9853dc8cdb5 --- /dev/null +++ b/server/fleet/apple_profiles.go @@ -0,0 +1,94 @@ +package fleet + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" +) + +// install/removeTargets are maps from profileUUID -> command uuid and host +// UUIDs as the underlying MDM services are optimized to send one command to +// multiple hosts at the same time. Note that the same command uuid is used +// for all hosts in a given install/remove target operation. +type CmdTarget struct { + CmdUUID string + ProfileIdentifier string + EnrollmentIDs []string +} + +type HostProfileUUID struct { + HostUUID string + ProfileUUID string +} + +func FindProfilesWithSecrets( + ctx context.Context, + logger *slog.Logger, + installTargets map[string]*CmdTarget, + profileContents map[string]mobileconfig.Mobileconfig, +) (map[string]struct{}, error) { + profilesWithSecrets := make(map[string]struct{}) + for profUUID := range installTargets { + p, ok := profileContents[profUUID] + if !ok { // Should never happen + logger.ErrorContext(ctx, "profile content not found in FindProfilesWithSecrets", "profile_uuid", profUUID) + continue + } + profileStr := string(p) + vars := ContainsPrefixVars(profileStr, ServerSecretPrefix) + if len(vars) > 0 { + profilesWithSecrets[profUUID] = struct{}{} + } + } + return profilesWithSecrets, nil +} + +func MarkProfilesFailed( + ctx context.Context, + ds Datastore, + logger *slog.Logger, + target *CmdTarget, + hostProfilesToInstallMap map[HostProfileUUID]*MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + profUUID string, + detail string, + variablesUpdatedAt *time.Time, +) (bool, error) { + profilesToUpdate := make([]*MDMAppleBulkUpsertHostProfilePayload, 0, len(target.EnrollmentIDs)) + for _, enrollmentID := range target.EnrollmentIDs { + profile, ok := GetHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID) + if !ok || profile == nil { + logger.ErrorContext(ctx, "failed to find profile to install by enrollment id, when marking as failed", "enrollment_id", enrollmentID, "profile_uuid", profUUID) + // Should never happen + continue + } + profile.Status = &MDMDeliveryFailed + profile.Detail = detail + profile.VariablesUpdatedAt = variablesUpdatedAt + profilesToUpdate = append(profilesToUpdate, profile) + } + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { + return false, fmt.Errorf("marking host profiles failed: %w", err) + } + return false, nil +} + +func GetHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap map[HostProfileUUID]*MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + enrollmentID, + profUUID string, +) (*MDMAppleBulkUpsertHostProfilePayload, bool) { + profile, ok := hostProfilesToInstallMap[HostProfileUUID{HostUUID: enrollmentID, ProfileUUID: profUUID}] + if !ok { + var hostUUID string + // If sending to the user channel the enrollmentID will have to be mapped back to the host UUID. + hostUUID, ok = userEnrollmentsToHostUUIDsMap[enrollmentID] + if ok { + profile, ok = hostProfilesToInstallMap[HostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] + } + } + return profile, ok +} diff --git a/server/fleet/cron_schedules.go b/server/fleet/cron_schedules.go index a9f3c8c4457..dff478e4496 100644 --- a/server/fleet/cron_schedules.go +++ b/server/fleet/cron_schedules.go @@ -52,6 +52,7 @@ const ( // CronSendRecoveryLockCommands sends SetRecoveryLock MDM commands to macOS devices. // Runs every 5 minutes. CronSendRecoveryLockCommands CronScheduleName = "send_recovery_lock_commands" + CronAppleMDMWorker CronScheduleName = "apple_mdm_worker" ) type CronSchedulesService interface { diff --git a/server/mdm/apple/profile_processor.go b/server/mdm/apple/profile_processor.go new file mode 100644 index 00000000000..b7e082158b3 --- /dev/null +++ b/server/mdm/apple/profile_processor.go @@ -0,0 +1,834 @@ +package apple_mdm + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "log/slog" + "maps" + "net/url" + "regexp" + "slices" + "strings" + "sync" + "time" + + "github.com/fleetdm/fleet/v4/ee/server/service/digicert" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/license" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/mdm/profiles" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/variables" + "github.com/google/uuid" +) + +// LEGACY VARIABLE +var fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserEmailIDP)) + +// EnqueueResult holds the results of profile enqueue operations. +type EnqueueResult struct { + // FailedCmdUUIDs maps command UUIDs that failed to enqueue to their errors. + FailedCmdUUIDs map[string]error + // SucceededCmdUUIDs contains the command UUIDs that were enqueued successfully. + SucceededCmdUUIDs []string +} + +func ProcessAndEnqueueProfiles(ctx context.Context, + ds fleet.Datastore, + logger *slog.Logger, + appConfig *fleet.AppConfig, + commander *MDMAppleCommander, + installTargets, removeTargets map[string]*fleet.CmdTarget, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, +) (*EnqueueResult, error) { + // Grab the contents of all the profiles we need to install + profileUUIDs := make([]string, 0, len(installTargets)) + for pUUID := range installTargets { + profileUUIDs = append(profileUUIDs, pUUID) + } + + profileContents, err := ds.GetMDMAppleProfilesContents(ctx, profileUUIDs) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get profile contents") + } + + groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting grouped certificate authorities") + } + + // Insert variables into profile contents of install targets. Variables may be host-specific. + err = preprocessProfileContents(ctx, appConfig, ds, + scep.NewSCEPConfigService(logger, nil), + digicert.NewService(digicert.WithLogger(logger)), + logger, installTargets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + if err != nil { + return nil, err + } + + // Find the profiles containing secret variables. + profilesWithSecrets, err := fleet.FindProfilesWithSecrets(ctx, logger, installTargets, profileContents) + if err != nil { + return nil, err + } + + type remoteResult struct { + Err error + CmdUUID string + } + + // Send the install/remove commands for each profile. + var wgProd, wgCons sync.WaitGroup + ch := make(chan remoteResult) + + execCmd := func(profUUID string, target *fleet.CmdTarget, op fleet.MDMOperationType) { + defer wgProd.Done() + + var err error + switch op { + case fleet.MDMOperationTypeInstall: + if _, ok := profilesWithSecrets[profUUID]; ok { + err = commander.EnqueueCommandInstallProfileWithSecrets(ctx, target.EnrollmentIDs, profileContents[profUUID], target.CmdUUID) + } else { + err = commander.InstallProfile(ctx, target.EnrollmentIDs, profileContents[profUUID], target.CmdUUID) + } + case fleet.MDMOperationTypeRemove: + err = commander.RemoveProfile(ctx, target.EnrollmentIDs, target.ProfileIdentifier, target.CmdUUID) + } + + var e *APNSDeliveryError + switch { + case errors.As(err, &e): + logger.DebugContext(ctx, "failed sending push notifications, profiles still enqueued", "details", err) + ch <- remoteResult{nil, target.CmdUUID} + // this is fine to pass as success here, since we have sent the command but just didn't notify the client, but when the client checks back in it will process this profile. + case err != nil: + logger.ErrorContext(ctx, fmt.Sprintf("enqueue command to %s profiles", op), "details", err) + ch <- remoteResult{err, target.CmdUUID} + default: + ch <- remoteResult{nil, target.CmdUUID} + } + } + for profUUID, target := range installTargets { + wgProd.Add(1) + go execCmd(profUUID, target, fleet.MDMOperationTypeInstall) + } + for profUUID, target := range removeTargets { + wgProd.Add(1) + go execCmd(profUUID, target, fleet.MDMOperationTypeRemove) + } + + result := &EnqueueResult{ + FailedCmdUUIDs: make(map[string]error), + SucceededCmdUUIDs: []string{}, + } + + wgCons.Go(func() { + for resp := range ch { + if resp.Err == nil { + result.SucceededCmdUUIDs = append(result.SucceededCmdUUIDs, resp.CmdUUID) + } else { + result.FailedCmdUUIDs[resp.CmdUUID] = resp.Err + } + } + }) + + wgProd.Wait() + close(ch) // done sending at this point, this triggers end of for loop in consumer + wgCons.Wait() + return result, nil +} + +func preprocessProfileContents( + ctx context.Context, + appConfig *fleet.AppConfig, + ds fleet.Datastore, + scepConfig fleet.SCEPConfigService, + digiCertService fleet.DigiCertService, + logger *slog.Logger, + targets map[string]*fleet.CmdTarget, + profileContents map[string]mobileconfig.Mobileconfig, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + groupedCAs *fleet.GroupedCertificateAuthorities, +) error { + // This method replaces Fleet variables ($FLEET_VAR_) in the profile + // contents, generating a unique profile for each host. For a 2KB profile and + // 30K hosts, this method may generate ~60MB of profile data in memory. + + var ( + // Copy of NDES SCEP config which will contain unencrypted password, if needed + ndesConfig *fleet.NDESSCEPProxyCA + digiCertCAs map[string]*fleet.DigiCertCA + customSCEPCAs map[string]*fleet.CustomSCEPProxyCA + smallstepCAs map[string]*fleet.SmallstepSCEPProxyCA + ) + + // this is used to cache the host ID corresponding to the UUID, so we don't + // need to look it up more than once per host. + hostIDForUUIDCache := make(map[string]uint) + + var addedTargets map[string]*fleet.CmdTarget + for profUUID, target := range targets { + contents, ok := profileContents[profUUID] + if !ok { + // This should never happen + continue + } + + // Check if Fleet variables are present. + contentsStr := string(contents) + fleetVars := variables.Find(contentsStr) + if len(fleetVars) == 0 { + continue + } + + var variablesUpdatedAt *time.Time + + // Do common validation that applies to all hosts in the target + valid := true + // Check if there are any CA variables first so that if a non-CA variable causes + // preprocessing to fail, we still set the variablesUpdatedAt timestamp so that + // validation works as expected + // In the future we should expand variablesUpdatedAt logic to include non-CA variables as + // well + for _, fleetVar := range fleetVars { + if fleetVar == string(fleet.FleetVarSCEPRenewalID) || + fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL) || fleetVar == string(fleet.FleetVarHostUUID) || + strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) || + strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) || + strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) { + // Give a few minutes leeway to account for clock skew + variablesUpdatedAt = ptr.Time(time.Now().UTC().Add(-3 * time.Minute)) + break + } + } + + initialFleetVarLoop: + for _, fleetVar := range fleetVars { + switch { + case fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL): + configured, err := isNDESSCEPConfigured(ctx, logger, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, target) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking NDES SCEP configuration") + } + if !configured { + valid = false + break initialFleetVarLoop + } + + case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP) || fleetVar == string(fleet.FleetVarHostHardwareSerial) || fleetVar == string(fleet.FleetVarHostPlatform) || + fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) || + fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || fleetVar == string(fleet.FleetVarSCEPRenewalID) || + fleetVar == string(fleet.FleetVarHostEndUserIDPFullname) || fleetVar == string(fleet.FleetVarHostUUID): + // No extra validation needed for these variables + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)): + caName, found := strings.CutPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) + if !found { + caName, _ = strings.CutPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) + } + + if digiCertCAs == nil { + digiCertCAs = make(map[string]*fleet.DigiCertCA) + } + configured, err := isDigiCertConfigured(ctx, logger, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, digiCertCAs, profUUID, target, caName, fleetVar) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking DigiCert configuration") + } + if !configured { + valid = false + break initialFleetVarLoop + } + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)): + caName, found := strings.CutPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) + if !found { + caName, _ = strings.CutPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) + } + + if customSCEPCAs == nil { + customSCEPCAs = make(map[string]*fleet.CustomSCEPProxyCA) + if groupedCAs != nil { + for _, ca := range groupedCAs.CustomScepProxy { + customSCEPCAs[ca.Name] = &ca + } + } + } + err := profiles.IsCustomSCEPConfigured(ctx, customSCEPCAs, caName, fleetVar, func(errMsg string) error { + _, err := fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, errMsg, ptr.Time(time.Now().UTC())) + return err + }) + if err != nil { + valid = false + break initialFleetVarLoop + } + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)): + if smallstepCAs == nil { + smallstepCAs = make(map[string]*fleet.SmallstepSCEPProxyCA) + } + caName, found := strings.CutPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) + if !found { + caName, _ = strings.CutPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) + } + + configured, err := isSmallstepSCEPConfigured(ctx, logger, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, smallstepCAs, profUUID, target, caName, + fleetVar) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking Smallstep SCEP configuration") + } + if !configured { + valid = false + break initialFleetVarLoop + } + + default: + // Otherwise, error out since this variable is unknown + detail := fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.", + fleetVar) + _, err := fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, detail, variablesUpdatedAt) + if err != nil { + return err + } + valid = false + } + } + if !valid { + // We marked the profile as failed, so we will not do any additional processing on it + delete(targets, profUUID) + continue + } + + // Currently, all supported Fleet variables are unique per host, so we split the profile into multiple profiles. + // We generate a new temporary profileUUID which is currently only used to install the profile. + // The profileUUID in host_mdm_apple_profiles is still the original profileUUID. + // We also generate a new commandUUID which is used to install the profile via nano_commands table. + if addedTargets == nil { + addedTargets = make(map[string]*fleet.CmdTarget, 1) + } + // We store the timestamp when the challenge was retrieved to know if it has expired. + var managedCertificatePayloads []*fleet.MDMManagedCertificate + // We need to update the profiles of each host with the new command UUID + profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.EnrollmentIDs)) + for _, enrollmentID := range target.EnrollmentIDs { + tempProfUUID := uuid.NewString() + // Use the same UUID for command UUID, which will be the primary key for nano_commands + tempCmdUUID := tempProfUUID + profile, ok := getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID) + if !ok || profile == nil { // Should never happen + continue + } + // Fetch the host UUID, which may not be the same as the Enrollment ID, from the profile + hostUUID := profile.HostUUID + + // some variables need more information about the host; build a skeleton host and hydrate if we need more info + hostLite := fleet.Host{UUID: hostUUID} + onMismatchedHostCount := func(hostCount int) error { + return ctxerr.Wrap(ctx, ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostLite.UUID, + Status: &fleet.MDMDeliveryFailed, + Detail: fmt.Sprintf("Unexpected number of hosts (%d) for UUID %s.", hostCount, hostLite.UUID), + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }), "could not retrieve host by UUID for profile variable substitution") + } + + profile.CommandUUID = tempCmdUUID + profile.VariablesUpdatedAt = variablesUpdatedAt + + hostContents := contentsStr + failed := false + + fleetVarLoop: + for _, fleetVar := range fleetVars { + var err error + switch { + case fleetVar == string(fleet.FleetVarNDESSCEPChallenge): + if ndesConfig == nil { + if groupedCAs == nil || groupedCAs.NDESSCEP == nil { + logger.ErrorContext(ctx, "missing NDES CA configuration for profile with NDES variables", "host_uuid", hostUUID, "profile_uuid", profUUID) + continue + } + ndesConfig = groupedCAs.NDESSCEP + } + logger.DebugContext(ctx, "fetching NDES challenge", "host_uuid", hostUUID, "profile_uuid", profUUID) + // Insert the SCEP challenge into the profile contents + challenge, err := scepConfig.GetNDESSCEPChallenge(ctx, *ndesConfig) + if err != nil { + detail := scep.NDESChallengeErrorToDetail(err) + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: detail, + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for NDES SCEP challenge") + } + failed = true + break fleetVarLoop + } + payload := &fleet.MDMManagedCertificate{ + HostUUID: hostUUID, + ProfileUUID: profUUID, + ChallengeRetrievedAt: ptr.Time(time.Now()), + Type: fleet.CAConfigNDES, + CAName: "NDES", + } + managedCertificatePayloads = append(managedCertificatePayloads, payload) + + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarNDESSCEPChallengeRegexp, hostContents, challenge) + + case fleetVar == string(fleet.FleetVarNDESSCEPProxyURL): + // Insert the SCEP URL into the profile contents + hostContents = profiles.ReplaceNDESSCEPProxyURLVariable(appConfig.MDMUrl(), hostUUID, profUUID, hostContents) + + case fleetVar == string(fleet.FleetVarSCEPRenewalID): + // Insert the SCEP renewal ID into the SCEP Payload CN or OU + fleetRenewalID := "fleet-" + profUUID + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPRenewalIDRegexp, hostContents, fleetRenewalID) + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)): + replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(ctx, logger, fleetVar, customSCEPCAs, hostContents) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing custom SCEP challenge variable") + } + if !replacedVariable { + continue + } + hostContents = replacedContents + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)): + replacedContents, managedCertificate, replacedVariable, err := profiles.ReplaceCustomSCEPProxyURLVariable(ctx, logger, ds, appConfig, fleetVar, customSCEPCAs, hostContents, hostUUID, profUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing custom SCEP proxy URL variable") + } + if !replacedVariable { + continue + } + hostContents = replacedContents + managedCertificatePayloads = append(managedCertificatePayloads, managedCertificate) + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)): + caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) + ca, ok := smallstepCAs[caName] + if !ok { + logger.ErrorContext(ctx, "Smallstep SCEP CA not found. "+ + "This error should never happen since we validated/populated CAs earlier", "ca_name", caName) + continue + } + logger.DebugContext(ctx, "fetching Smallstep SCEP challenge", "host_uuid", hostUUID, "profile_uuid", profUUID) + challenge, err := scepConfig.GetSmallstepSCEPChallenge(ctx, *ca) + if err != nil { + detail := fmt.Sprintf("Fleet couldn't populate $FLEET_VAR_%s. %s", fleet.FleetVarSmallstepSCEPChallengePrefix, err.Error()) + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: detail, + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for Smallstep SCEP challenge") + } + failed = true + break fleetVarLoop + } + logger.InfoContext(ctx, "retrieved SCEP challenge from Smallstep", "host_uuid", hostUUID, "profile_uuid", profUUID) + + payload := &fleet.MDMManagedCertificate{ + HostUUID: hostUUID, + ProfileUUID: profUUID, + ChallengeRetrievedAt: ptr.Time(time.Now()), + Type: fleet.CAConfigSmallstep, + CAName: caName, + } + managedCertificatePayloads = append(managedCertificatePayloads, payload) + hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPChallengePrefix), ca.Name, hostContents, challenge) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP challenge variable") + } + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)): + // Insert the SCEP URL into the profile contents + caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) + proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), SCEPProxyPath, + url.PathEscape(fmt.Sprintf("%s,%s,%s", hostUUID, profUUID, caName))) + hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPProxyURLPrefix), caName, hostContents, proxyURL) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP URL variable") + } + + case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP): + // FIXME: if this is used together with a CA, and fail inside getFirstIDPEmail, the profile will fail, but not get the correct variablesUpdatedAt var. + email, ok, err := getFirstIDPEmail(ctx, ds, target, hostUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting IDP email") + } + if !ok { + failed = true + break fleetVarLoop + } + hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, hostContents, email) + + case fleetVar == string(fleet.FleetVarHostHardwareSerial): + hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting host hardware serial") + } + if !ok { + failed = true + break fleetVarLoop + } + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, hostContents, hostLite.HardwareSerial) + case fleetVar == string(fleet.FleetVarHostPlatform): + hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting host platform") + } + if !ok { + failed = true + break fleetVarLoop + } + platform := hostLite.Platform + if platform == "darwin" { + platform = "macos" + } + + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, hostContents, platform) + case fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) || + fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || + fleetVar == string(fleet.FleetVarHostEndUserIDPFullname): + replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserIDPVariables(ctx, ds, fleetVar, hostContents, hostUUID, hostIDForUUIDCache, func(errMsg string) error { + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: errMsg, + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }) + return err + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing host end user IDP variables") + } + if !replacedVariable { + failed = true + break fleetVarLoop + } + + hostContents = replacedContents + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)): + // We will replace the password when we populate the certificate data + + case fleetVar == string(fleet.FleetVarHostUUID): + hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostUUIDRegexp, hostContents, hostUUID) + + case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)): + caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) + ca, ok := digiCertCAs[caName] + if !ok { + logger.ErrorContext(ctx, "Custom DigiCert CA not found. "+ + "This error should never happen since we validated/populated CAs earlier", "ca_name", caName) + continue + } + caCopy := *ca + // Deep copy the UPN slice to prevent cross-host contamination: a + // shallow copy shares the backing array, so in-place substitutions for + // one host would corrupt the cached CA used by subsequent hosts. + caCopy.CertificateUserPrincipalNames = slices.Clone(ca.CertificateUserPrincipalNames) + + // Populate Fleet vars in the CA fields + caVarsCache := make(map[string]string) + + ok, err := replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateCommonName, onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") + } + if !ok { + failed = true + break fleetVarLoop + } + ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateSeatID, onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") + } + if !ok { + failed = true + break fleetVarLoop + } + if len(caCopy.CertificateUserPrincipalNames) > 0 { + for i := range caCopy.CertificateUserPrincipalNames { + ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateUserPrincipalNames[i], onMismatchedHostCount) + if err != nil { + return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") + } + if !ok { + failed = true + break fleetVarLoop + } + } + } + + cert, err := digiCertService.GetCertificate(ctx, caCopy) + if err != nil { + detail := fmt.Sprintf("Couldn't get certificate from DigiCert for %s. %s", caCopy.Name, err) + err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: detail, + OperationType: fleet.MDMOperationTypeInstall, + VariablesUpdatedAt: variablesUpdatedAt, + }) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for DigiCert") + } + failed = true + break fleetVarLoop + } + hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertDataPrefix), caName, hostContents, + base64.StdEncoding.EncodeToString(cert.PfxData)) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert data") + } + hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertPasswordPrefix), caName, hostContents, cert.Password) + if err != nil { + return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert password") + } + managedCertificatePayloads = append(managedCertificatePayloads, &fleet.MDMManagedCertificate{ + HostUUID: hostUUID, + ProfileUUID: profUUID, + NotValidBefore: &cert.NotValidBefore, + NotValidAfter: &cert.NotValidAfter, + Type: fleet.CAConfigDigiCert, + CAName: caName, + Serial: &cert.SerialNumber, + }) + + default: + // This was handled in the above switch statement, so we should never reach this case + } + } + if !failed { + addedTargets[tempProfUUID] = &fleet.CmdTarget{ + CmdUUID: tempCmdUUID, + ProfileIdentifier: target.ProfileIdentifier, + EnrollmentIDs: []string{enrollmentID}, + } + profileContents[tempProfUUID] = mobileconfig.Mobileconfig(hostContents) + profilesToUpdate = append(profilesToUpdate, profile) + } + } + // Update profiles with the new command UUID + if len(profilesToUpdate) > 0 { + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { + return ctxerr.Wrap(ctx, err, "updating host profiles") + } + } + if len(managedCertificatePayloads) != 0 { + // TODO: We could filter out failed profiles, but at the moment we don't, see Windows impl. for how it's done there. + err := ds.BulkUpsertMDMManagedCertificates(ctx, managedCertificatePayloads) + if err != nil { + return ctxerr.Wrap(ctx, err, "updating managed certificates") + } + } + // Remove the parent target, since we will use host-specific targets + delete(targets, profUUID) + } + if len(addedTargets) > 0 { + // Add the new host-specific targets to the original targets map + maps.Copy(targets, addedTargets) + } + return nil +} + +func getFirstIDPEmail(ctx context.Context, ds fleet.Datastore, target *fleet.CmdTarget, hostUUID string) (string, bool, error) { + // Insert the end user email IDP into the profile contents + emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts) + if err != nil { + // This is a server error, so we exit. + return "", false, ctxerr.Wrap(ctx, err, "getting host emails") + } + if len(emails) == 0 { + // We couldn't retrieve the end user email IDP, so mark the profile as failed with additional detail. + err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ + CommandUUID: target.CmdUUID, + HostUUID: hostUUID, + Status: &fleet.MDMDeliveryFailed, + Detail: fmt.Sprintf("There is no IdP email for this host. "+ + "Fleet couldn't populate $FLEET_VAR_%s. "+ + "[Learn more](https://fleetdm.com/learn-more-about/idp-email)", + fleet.FleetVarHostEndUserEmailIDP), + OperationType: fleet.MDMOperationTypeInstall, + }) + if err != nil { + return "", false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user email IdP") + } + return "", false, nil + } + return emails[0], true, nil +} + +func replaceFleetVarInItem(ctx context.Context, ds fleet.Datastore, target *fleet.CmdTarget, hostLite fleet.Host, caVarsCache map[string]string, item *string, onMismatchedHostCount func(int) error) (bool, error) { + caFleetVars := variables.Find(*item) + for _, caVar := range caFleetVars { + switch caVar { + case string(fleet.FleetVarHostEndUserEmailIDP): + email, ok := caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] + if !ok { + var err error + email, ok, err = getFirstIDPEmail(ctx, ds, target, hostLite.UUID) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "getting IDP email") + } + if !ok { + return false, nil + } + caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] = email + } + *item = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, *item, email) + case string(fleet.FleetVarHostHardwareSerial): + hardwareSerial, ok := caVarsCache[string(fleet.FleetVarHostHardwareSerial)] + if !ok { + var err error + hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "getting host hardware serial") + } + if !ok { + return false, nil + } + hardwareSerial = hostLite.HardwareSerial + caVarsCache[string(fleet.FleetVarHostHardwareSerial)] = hostLite.HardwareSerial + } + *item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, *item, hardwareSerial) + case string(fleet.FleetVarHostPlatform): + platform, ok := caVarsCache[string(fleet.FleetVarHostPlatform)] + if !ok { + var err error + hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) + if err != nil { + return false, ctxerr.Wrap(ctx, err, "getting host hardware serial") + } + if !ok { + return false, nil + } + platform = hostLite.Platform + if platform == "darwin" { + platform = "macos" + } + + caVarsCache[string(fleet.FleetVarHostPlatform)] = platform + } + *item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, *item, platform) + default: + // We should not reach this since we validated the variables when saving app config + } + } + return true, nil +} + +func isDigiCertConfigured(ctx context.Context, logger *slog.Logger, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + existingDigiCertCAs map[string]*fleet.DigiCertCA, profUUID string, target *fleet.CmdTarget, caName string, fleetVar string, +) (bool, error) { + if !license.IsPremium(ctx) { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "DigiCert integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) + } + if _, ok := existingDigiCertCAs[caName]; ok { + return true, nil + } + configured := false + var digiCertCA *fleet.DigiCertCA + if groupedCAs != nil && len(groupedCAs.DigiCert) > 0 { + for _, ca := range groupedCAs.DigiCert { + if ca.Name == caName { + digiCertCA = &ca + configured = true + break + } + } + } + if !configured || digiCertCA == nil { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, + fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC())) + } + + existingDigiCertCAs[caName] = digiCertCA + return true, nil +} + +func isNDESSCEPConfigured(ctx context.Context, logger *slog.Logger, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, userEnrollmentsToHostUUIDsMap map[string]string, profUUID string, target *fleet.CmdTarget, +) (bool, error) { + if !license.IsPremium(ctx) { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "NDES SCEP Proxy requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) + } + if groupedCAs == nil || groupedCAs.NDESSCEP == nil { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, + "NDES SCEP Proxy is not configured. Please configure in Settings > Integrations > Certificates.", ptr.Time(time.Now().UTC())) + } + return true, nil +} + +func isSmallstepSCEPConfigured(ctx context.Context, logger *slog.Logger, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, + hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + existingSmallstepSCEPCAs map[string]*fleet.SmallstepSCEPProxyCA, profUUID string, target *fleet.CmdTarget, caName string, fleetVar string, +) (bool, error) { + if !license.IsPremium(ctx) { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "Smallstep SCEP integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) + } + if _, ok := existingSmallstepSCEPCAs[caName]; ok { + return true, nil + } + configured := false + var scepCA *fleet.SmallstepSCEPProxyCA + if groupedCAs != nil && len(groupedCAs.Smallstep) > 0 { + for _, ca := range groupedCAs.Smallstep { + if ca.Name == caName { + scepCA = &ca + configured = true + break + } + } + } + if !configured || scepCA == nil { + return fleet.MarkProfilesFailed(ctx, ds, logger, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, + fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC())) + } + + existingSmallstepSCEPCAs[caName] = scepCA + return true, nil +} + +func getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, + userEnrollmentsToHostUUIDsMap map[string]string, + enrollmentID, + profUUID string, +) (*fleet.MDMAppleBulkUpsertHostProfilePayload, bool) { + profile, ok := hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: enrollmentID, ProfileUUID: profUUID}] + if !ok { + var hostUUID string + // If sending to the user channel the enrollmentID will have to be mapped back to the host UUID. + hostUUID, ok = userEnrollmentsToHostUUIDsMap[enrollmentID] + if ok { + profile, ok = hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] + } + } + return profile, ok +} diff --git a/server/mdm/apple/profile_processor_test.go b/server/mdm/apple/profile_processor_test.go new file mode 100644 index 00000000000..82323f58706 --- /dev/null +++ b/server/mdm/apple/profile_processor_test.go @@ -0,0 +1,999 @@ +package apple_mdm + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/ee/server/service/digicert" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" + "github.com/fleetdm/fleet/v4/server/contexts/license" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" + "github.com/fleetdm/fleet/v4/server/mock" + digicert_mock "github.com/fleetdm/fleet/v4/server/mock/digicert" + scep_mock "github.com/fleetdm/fleet/v4/server/mock/scep" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPreprocessProfileContents(t *testing.T) { + ctx := context.Background() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + appCfg := &fleet.AppConfig{} + appCfg.ServerSettings.ServerURL = "https://test.example.com" + appCfg.MDM.EnabledAndConfigured = true + ds := new(mock.Store) + + // No-op + svc := scep.NewSCEPConfigService(logger, nil) + digiCertService := digicert.NewService(digicert.WithLogger(logger)) + err := preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, nil, nil, nil, nil, nil) + require.NoError(t, err) + + hostUUID := "host-1" + cmdUUID := "cmd-1" + var targets map[string]*fleet.CmdTarget + populateTargets := func() { + targets = map[string]*fleet.CmdTarget{ + "p1": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile", EnrollmentIDs: []string{hostUUID}}, + } + } + hostProfilesToInstallMap := make(map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, 1) + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: hostUUID, ProfileUUID: "p1"}] = &fleet.MDMAppleBulkUpsertHostProfilePayload{ + ProfileUUID: "p1", + ProfileIdentifier: "com.add.profile", + HostUUID: hostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + CommandUUID: cmdUUID, + Scope: fleet.PayloadScopeSystem, + } + userEnrollmentsToHostUUIDsMap := make(map[string]string) + populateTargets() + profileContents := map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), + } + + var updatedPayload *fleet.MDMAppleBulkUpsertHostProfilePayload + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + require.Len(t, payload, 1) + updatedPayload = payload[0] + for _, p := range payload { + require.NotNil(t, p.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *p.Status) + assert.Equal(t, cmdUUID, p.CommandUUID) + assert.Equal(t, hostUUID, p.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) + assert.Equal(t, fleet.PayloadScopeSystem, p.Scope) + } + return nil + } + // Can't use NDES SCEP proxy with free tier + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree}) + err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) + require.NoError(t, err) + require.NotNil(t, updatedPayload) + assert.Contains(t, updatedPayload.Detail, "Premium license") + assert.Empty(t, targets) + + // Can't use NDES SCEP proxy without it being configured + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + updatedPayload = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, &fleet.GroupedCertificateAuthorities{}) + require.NoError(t, err) + require.NotNil(t, updatedPayload) + assert.Contains(t, updatedPayload.Detail, "not configured") + assert.NotNil(t, updatedPayload.VariablesUpdatedAt) + assert.Empty(t, targets) + + // Unknown variable + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_BOZO"), + } + updatedPayload = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) + require.NoError(t, err) + require.NotNil(t, updatedPayload) + assert.Contains(t, updatedPayload.Detail, "FLEET_VAR_BOZO") + assert.Empty(t, targets) + + ndesPassword := "test-password" + ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, + assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext, + ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { + return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ + fleet.MDMAssetNDESPassword: {Value: []byte(ndesPassword)}, + }, nil + } + + ds.BulkUpsertMDMAppleHostProfilesFunc = nil + var updatedProfile *fleet.HostMDMAppleProfile + ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { + updatedProfile = profile + require.NotNil(t, updatedProfile.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) + assert.Equal(t, cmdUUID, updatedProfile.CommandUUID) + assert.Equal(t, hostUUID, updatedProfile.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) + return nil + } + ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { + assert.Empty(t, payload) + return nil + } + + adminUrl := "https://example.com" + username := "admin" + password := "test-password" + groupedCAs := &fleet.GroupedCertificateAuthorities{ + NDESSCEP: &fleet.NDESSCEPProxyCA{ + URL: "https://test-example.com", + AdminURL: adminUrl, + Username: username, + Password: password, + }, + } + + // Could not get NDES SCEP challenge + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPChallenge), + } + scepConfig := &scep_mock.SCEPConfigService{} + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", scep.NewNDESInvalidError("NDES error") + } + updatedProfile = nil + populateTargets() + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + assert.Empty(t, payload) // no profiles to update since FLEET VAR could not be populated + return nil + } + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) + assert.Contains(t, updatedProfile.Detail, "update credentials") + assert.NotNil(t, updatedProfile.VariablesUpdatedAt) + assert.Empty(t, targets) + + // Password cache full + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", scep.NewNDESPasswordCacheFullError("NDES error") + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) + assert.Contains(t, updatedProfile.Detail, "cached passwords") + assert.NotNil(t, updatedProfile.VariablesUpdatedAt) + assert.Empty(t, targets) + + // Insufficient permissions + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", scep.NewNDESInsufficientPermissionsError("NDES error") + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) + assert.Contains(t, updatedProfile.Detail, "does not have sufficient permissions") + assert.NotNil(t, updatedProfile.VariablesUpdatedAt) + assert.Empty(t, targets) + + // Other NDES challenge error + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return "", errors.New("NDES error") + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) + assert.NotContains(t, updatedProfile.Detail, "cached passwords") + assert.NotContains(t, updatedProfile.Detail, "update credentials") + assert.NotNil(t, updatedProfile.VariablesUpdatedAt) + assert.Empty(t, targets) + + // NDES challenge + challenge := "ndes-challenge" + scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { + assert.Equal(t, ndesPassword, proxy.Password) + return challenge, nil + } + updatedProfile = nil + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + for _, p := range payload { + assert.NotEqual(t, cmdUUID, p.CommandUUID) + } + return nil + } + populateTargets() + ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { + require.Len(t, payload, 1) + assert.NotNil(t, payload[0].ChallengeRetrievedAt) + return nil + } + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.NotEqual(t, cmdUUID, target.CmdUUID) + assert.Equal(t, []string{hostUUID}, target.EnrollmentIDs) + assert.Equal(t, challenge, string(profileContents[profUUID])) + } + + // NDES SCEP proxy URL + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), + } + expectedURL := "https://test.example.com" + SCEPProxyPath + url.QueryEscape(fmt.Sprintf("%s,%s,NDES", hostUUID, "p1")) + updatedProfile = nil + populateTargets() + ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { + assert.Empty(t, payload) + return nil + } + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.NotEqual(t, cmdUUID, target.CmdUUID) + assert.Equal(t, []string{hostUUID}, target.EnrollmentIDs) + assert.Equal(t, expectedURL, string(profileContents[profUUID])) + } + + // No IdP email found + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return nil, nil + } + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP), + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarHostEndUserEmailIDP) + assert.Contains(t, updatedProfile.Detail, "no IdP email") + assert.Empty(t, targets) + + // IdP email found + email := "user@example.com" + ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { + return []string{email}, nil + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.NotEqual(t, cmdUUID, target.CmdUUID) + assert.Equal(t, []string{hostUUID}, target.EnrollmentIDs) + assert.Equal(t, email, string(profileContents[profUUID])) + } + + // Hardware serial + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + assert.Equal(t, []string{hostUUID}, uuids) + return []*fleet.Host{ + {HardwareSerial: "serial1"}, + }, nil + } + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostHardwareSerial), + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + assert.Nil(t, updatedProfile) + require.NotEmpty(t, targets) + assert.Len(t, targets, 1) + for profUUID, target := range targets { + assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host + assert.NotEqual(t, cmdUUID, target.CmdUUID) + assert.Equal(t, []string{hostUUID}, target.EnrollmentIDs) + assert.Equal(t, "serial1", string(profileContents[profUUID])) + } + + // Hardware serial fail + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + assert.Equal(t, []string{hostUUID}, uuids) + return nil, nil + } + updatedProfile = nil + populateTargets() + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotNil(t, updatedProfile) + assert.Contains(t, updatedProfile.Detail, "Unexpected number of hosts (0) for UUID") + assert.Empty(t, targets) + + // multiple profiles, multiple hosts + populateTargets = func() { + targets = map[string]*fleet.CmdTarget{ + "p1": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile", EnrollmentIDs: []string{hostUUID, "host-2"}}, // fails + "p2": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile2", EnrollmentIDs: []string{hostUUID, "host-3"}}, // works + "p3": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile3", EnrollmentIDs: []string{hostUUID, "host-4"}}, // no variables + } + } + populateTargets() + groupedCAs.NDESSCEP = nil + profileContents = map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), + "p2": []byte("$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP), + "p3": []byte("no variables"), + } + addProfileToInstall := func(hostUUID, profileUUID, profileIdentifier string) { + hostProfilesToInstallMap[fleet.HostProfileUUID{ + HostUUID: hostUUID, + ProfileUUID: profileUUID, + }] = &fleet.MDMAppleBulkUpsertHostProfilePayload{ + ProfileUUID: profileUUID, + ProfileIdentifier: profileIdentifier, + HostUUID: hostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + CommandUUID: cmdUUID, + Scope: fleet.PayloadScopeSystem, + } + } + addProfileToInstall(hostUUID, "p1", "com.add.profile") + addProfileToInstall("host-2", "p1", "com.add.profile") + addProfileToInstall(hostUUID, "p2", "com.add.profile2") + addProfileToInstall("host-3", "p2", "com.add.profile2") + addProfileToInstall(hostUUID, "p3", "com.add.profile3") + addProfileToInstall("host-4", "p3", "com.add.profile3") + expectedHostsToFail := []string{hostUUID, "host-2", "host-3"} + ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { + updatedProfile = profile + require.NotNil(t, updatedProfile.Status) + assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) + assert.NotEqual(t, cmdUUID, updatedProfile.CommandUUID) + assert.Contains(t, expectedHostsToFail, updatedProfile.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) + return nil + } + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + for _, p := range payload { + require.NotNil(t, p.Status) + if fleet.MDMDeliveryFailed == *p.Status { + assert.Equal(t, cmdUUID, p.CommandUUID) + } else { + assert.NotEqual(t, cmdUUID, p.CommandUUID) + } + assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) + } + return nil + } + err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) + require.NoError(t, err) + require.NotEmpty(t, targets) + assert.Len(t, targets, 3) + assert.Nil(t, targets["p1"]) // error + assert.Nil(t, targets["p2"]) // renamed + assert.NotNil(t, targets["p3"]) // normal, no variables + for profUUID, target := range targets { + assert.Contains(t, [][]string{{hostUUID}, {"host-3"}, {hostUUID, "host-4"}}, target.EnrollmentIDs) + if profUUID == "p3" { + assert.Equal(t, cmdUUID, target.CmdUUID) + } else { + assert.NotEqual(t, cmdUUID, target.CmdUUID) + } + assert.Contains(t, []string{email, "no variables"}, string(profileContents[profUUID])) + } +} + +// TestPreprocessProfileContentsDigiCertUPNMultiHost is a regression test for +// https://github.com/fleetdm/fleet/issues/39324. When the same DigiCert CA is +// used for multiple hosts in a single batch, the CertificateUserPrincipalNames +// slice was shared via a shallow copy. In-place variable substitution for Host 1 +// corrupted the cached CA entry, so Host 2 and later hosts received Host 1's +// substituted UPN instead of their own. +func TestPreprocessProfileContentsDigiCertUPNIsUniqueForMultipleHosts(t *testing.T) { + ctx := context.Background() + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) + logger := slog.New(slog.DiscardHandler) + appCfg := &fleet.AppConfig{} + appCfg.ServerSettings.ServerURL = "https://test.example.com" + appCfg.MDM.EnabledAndConfigured = true + ds := new(mock.Store) + + svc := scep.NewSCEPConfigService(logger, nil) + + const caName = "myCA" + const host1UUID = "host-uuid-1" + const host2UUID = "host-uuid-2" + const host1Serial = "SERIAL-AAA" + const host2Serial = "SERIAL-BBB" + const cmdUUID = "cmd-uuid-1" + + // Track which UPNs were sent to GetCertificate per host. + upnByHostUUID := make(map[string]string) + + mockDigiCert := &digicert_mock.Service{} + mockDigiCert.GetCertificateFunc = func(ctx context.Context, config fleet.DigiCertCA) (*fleet.DigiCertCertificate, error) { + // The UPN in config should have been substituted with each host's own + // hardware serial. Record it so we can assert correctness later. + require.Len(t, config.CertificateUserPrincipalNames, 1) + upn := config.CertificateUserPrincipalNames[0] + // Determine which host this call is for by checking which serial is in the UPN. + switch { + case strings.Contains(upn, host1Serial): + upnByHostUUID[host1UUID] = upn + case strings.Contains(upn, host2Serial): + upnByHostUUID[host2UUID] = upn + default: + t.Errorf("GetCertificate called with unexpected UPN %q", upn) + } + now := time.Now() + return &fleet.DigiCertCertificate{ + PfxData: []byte("fake-pfx"), + Password: "fake-password", + NotValidBefore: now, + NotValidAfter: now.Add(365 * 24 * time.Hour), + SerialNumber: upn, // reuse upn as serial for easy tracing + }, nil + } + + // Both hosts share the same profile UUID but are separate enrollment IDs. + targets := map[string]*fleet.CmdTarget{ + "p1": { + CmdUUID: cmdUUID, + ProfileIdentifier: "com.apple.security.pkcs12", + EnrollmentIDs: []string{host1UUID, host2UUID}, + }, + } + + pending := fleet.MDMDeliveryPending + hostProfilesToInstallMap := map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload{ + {HostUUID: host1UUID, ProfileUUID: "p1"}: { + ProfileUUID: "p1", + ProfileIdentifier: "com.apple.security.pkcs12", + HostUUID: host1UUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &pending, + CommandUUID: cmdUUID, + Scope: fleet.PayloadScopeSystem, + }, + {HostUUID: host2UUID, ProfileUUID: "p1"}: { + ProfileUUID: "p1", + ProfileIdentifier: "com.apple.security.pkcs12", + HostUUID: host2UUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &pending, + CommandUUID: cmdUUID, + Scope: fleet.PayloadScopeSystem, + }, + } + + // Profile contains both DigiCert fleet variables. + profileContents := map[string]mobileconfig.Mobileconfig{ + "p1": []byte("$FLEET_VAR_" + string(fleet.FleetVarDigiCertPasswordPrefix) + caName + " $FLEET_VAR_" + string(fleet.FleetVarDigiCertDataPrefix) + caName), + } + + // DigiCert CA whose UPN contains the hardware serial variable. + groupedCAs := &fleet.GroupedCertificateAuthorities{ + DigiCert: []fleet.DigiCertCA{ + { + Name: caName, + URL: "https://digicert.example.com", + APIToken: "api_token", + ProfileID: "profile_id", + CertificateCommonName: "common_name", + CertificateUserPrincipalNames: []string{"$FLEET_VAR_" + string(fleet.FleetVarHostHardwareSerial) + "@example.com"}, + CertificateSeatID: "seat_id", + }, + }, + } + + // Mock datastore: return each host's own hardware serial when queried. + hostsByUUID := map[string]*fleet.Host{ + host1UUID: {ID: 1, UUID: host1UUID, HardwareSerial: host1Serial, Platform: "darwin"}, + host2UUID: {ID: 2, UUID: host2UUID, HardwareSerial: host2Serial, Platform: "darwin"}, + } + ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { + var hosts []*fleet.Host + for _, uuid := range uuids { + if h, ok := hostsByUUID[uuid]; ok { + hosts = append(hosts, h) + } + } + return hosts, nil + } + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + return nil + } + ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { + return nil + } + + err := preprocessProfileContents(ctx, appCfg, ds, svc, mockDigiCert, logger, targets, profileContents, hostProfilesToInstallMap, make(map[string]string), groupedCAs) + require.NoError(t, err) + + // Both hosts must have received GetCertificate calls with their own serial. + require.True(t, mockDigiCert.GetCertificateFuncInvoked, "GetCertificate was never called") + require.Contains(t, upnByHostUUID, host1UUID, "GetCertificate was not called for host 1") + require.Contains(t, upnByHostUUID, host2UUID, "GetCertificate was not called for host 2") + assert.Equal(t, host1Serial+"@example.com", upnByHostUUID[host1UUID], "host 1 UPN should contain its own serial") + assert.Equal(t, host2Serial+"@example.com", upnByHostUUID[host2UUID], "host 2 UPN should contain its own serial") +} + +func TestPreprocessProfileContentsEndUserIDP(t *testing.T) { + ctx := context.Background() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + appCfg := &fleet.AppConfig{} + appCfg.ServerSettings.ServerURL = "https://test.example.com" + appCfg.MDM.EnabledAndConfigured = true + ds := new(mock.Store) + + svc := scep.NewSCEPConfigService(logger, nil) + digiCertService := digicert.NewService(digicert.WithLogger(logger)) + + hostUUID := "host-1" + cmdUUID := "cmd-1" + var targets map[string]*fleet.CmdTarget + // this is a func to re-create it each time because calling the preprocess function modifies this + populateTargets := func() { + targets = map[string]*fleet.CmdTarget{ + "p1": {CmdUUID: cmdUUID, ProfileIdentifier: "com.add.profile", EnrollmentIDs: []string{hostUUID}}, + } + } + hostProfilesToInstallMap := map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload{ + {HostUUID: hostUUID, ProfileUUID: "p1"}: { + ProfileUUID: "p1", + ProfileIdentifier: "com.add.profile", + HostUUID: hostUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: &fleet.MDMDeliveryPending, + CommandUUID: cmdUUID, + }, + } + + userEnrollmentsToHostUUIDsMap := make(map[string]string) + + var updatedPayload *fleet.MDMAppleBulkUpsertHostProfilePayload + var expectedStatus fleet.MDMDeliveryStatus + ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { + require.Len(t, payload, 1) + updatedPayload = payload[0] + require.NotNil(t, updatedPayload.Status) + assert.Equal(t, expectedStatus, *updatedPayload.Status) + // cmdUUID was replaced by a new unique command on success + assert.NotEqual(t, cmdUUID, updatedPayload.CommandUUID) + assert.Equal(t, hostUUID, updatedPayload.HostUUID) + assert.Equal(t, fleet.MDMOperationTypeInstall, updatedPayload.OperationType) + return nil + } + ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, idents []string) ([]uint, error) { + require.Len(t, idents, 1) + require.Equal(t, hostUUID, idents[0]) + return []uint{1}, nil + } + var updatedProfile *fleet.HostMDMAppleProfile + ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { + updatedProfile = profile + require.NotNil(t, profile.Status) + assert.Equal(t, expectedStatus, *profile.Status) + return nil + } + ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) { + return []*fleet.CertificateAuthority{}, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + + cases := []struct { + desc string + profileContent string + expectedStatus fleet.MDMDeliveryStatus + setup func() + assert func(output string) + }{ + { + desc: "username only scim", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1@example.com", output) + }, + }, + { + desc: "username local part only scim", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1", output) + }, + }, + { + desc: "groups only scim", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + Groups: []fleet.ScimUserGroup{{DisplayName: "a"}, {DisplayName: "b"}}, + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "a,b", output) + }, + }, + { + desc: "multiple times username only scim", + profileContent: strings.Repeat("${FLEET_VAR_"+string(fleet.FleetVarHostEndUserIDPUsername)+"}", 3), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1@example.comuser1@example.comuser1@example.com", output) + }, + }, + { + desc: "all 3 vars with scim", + profileContent: "${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername) + "}${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) + "}${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups) + "}", + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + Groups: []fleet.ScimUserGroup{{DisplayName: "a"}, {DisplayName: "b"}}, + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1@example.comuser1a,b", output) + }, + }, + { + desc: "username no scim, with idp", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "idp@example.com", + Groups: []fleet.ScimUserGroup{{DisplayName: "a"}, {DisplayName: "b"}}, + }, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{ + { + Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles, + }, + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "idp@example.com", output) + }, + }, + { + desc: "username scim and idp", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{ + { + Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles, + }, + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "user1@example.com", output) + }, + }, + { + desc: "username, no idp user", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{}, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{ + { + Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles, + }, + }, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME.") + }, + }, + { + desc: "username local part, no idp user", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{}, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return []*fleet.HostDeviceMapping{ + { + Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles, + }, + }, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART.") + }, + }, + { + desc: "groups, no idp user", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{}, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_GROUPS.") + }, + }, + { + desc: "department, no idp user", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{}, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT.") + }, + }, + { + desc: "groups with user groups, user has no groups", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_GROUPS.") + }, + }, + { + desc: "profile with department, user has department", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + Department: ptr.String("Engineering"), + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "Engineering", output) + }, + }, + { + desc: "profile with department, user has no department", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Len(t, targets, 0) // target is not present + assert.Contains(t, updatedProfile.Detail, "There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT.") + }, + }, + { + desc: "profile with full name, user has full name", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + GivenName: ptr.String("First"), + FamilyName: ptr.String("Last"), + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "First Last", output) + }, + }, + { + desc: "profile with full name, user only has given name", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + GivenName: ptr.String("First"), + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "First", output) + }, + }, + { + desc: "profile with full name, user only has family name", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), + expectedStatus: fleet.MDMDeliveryPending, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + FamilyName: ptr.String("Last"), + }, nil + } + }, + assert: func(output string) { + assert.Empty(t, updatedPayload.Detail) // no error detail + assert.Len(t, targets, 1) // target is still present + require.Equal(t, "Last", output) + }, + }, + { + desc: "profile with full name, user has no full name value", + profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), + expectedStatus: fleet.MDMDeliveryFailed, + setup: func() { + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + require.EqualValues(t, 1, hostID) + return &fleet.ScimUser{ + UserName: "user1@example.com", + }, nil + } + }, + assert: func(output string) { + assert.Contains(t, updatedProfile.Detail, fmt.Sprintf("There is no IdP full name for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPFullname)) + assert.Len(t, targets, 0) + }, + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + c.setup() + + profileContents := map[string]mobileconfig.Mobileconfig{ + "p1": []byte(c.profileContent), + } + populateTargets() + expectedStatus = c.expectedStatus + updatedPayload = nil + updatedProfile = nil + + err := preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) + require.NoError(t, err) + var output string + if expectedStatus == fleet.MDMDeliveryFailed { + require.Nil(t, updatedPayload) + require.NotNil(t, updatedProfile) + } else { + require.NotNil(t, updatedPayload) + require.Nil(t, updatedProfile) + output = string(profileContents[updatedPayload.CommandUUID]) + } + + c.assert(output) + }) + } +} diff --git a/server/ptr/ptr.go b/server/ptr/ptr.go index 22b7734c279..ecc564c9f17 100644 --- a/server/ptr/ptr.go +++ b/server/ptr/ptr.go @@ -8,7 +8,9 @@ import ( // String returns a pointer to the provided string. func String(x string) *string { - return &x + val := new(string) + *val = x + return val } // Int returns a pointer to the provided int. @@ -49,7 +51,9 @@ func StringPtr(x string) **string { // Time returns a pointer to the provided time.Time. func Time(x time.Time) *time.Time { - return &x + val := new(time.Time) + *val = x + return val } // TimePtr returns a *time.Time Pointer (**time.Time) for the provided time. diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 67615dfe260..538a6925eef 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -22,11 +22,8 @@ import ( "sort" "strconv" "strings" - "sync" "time" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" - "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/pkg/file" shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm" "github.com/fleetdm/fleet/v4/pkg/optjson" @@ -36,7 +33,6 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" - "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" @@ -54,7 +50,6 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service" - "github.com/fleetdm/fleet/v4/server/mdm/profiles" "github.com/fleetdm/fleet/v4/server/platform/endpointer" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/variables" @@ -69,24 +64,13 @@ const ( limit10KiB = 10 * 1024 ) -var ( - fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserEmailIDP)) - fleetVarSCEPRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarSCEPRenewalID)) - fleetVarHostUUIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostUUID)) - - // TODO(HCA): Can we come up with a clearer name? This looks like any variables not in this slice is not supported, - // but that is not the case, digicert, custom scep, hydrant and smallstep are totally supported just in a different way (multiple CA's) - fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{ - fleet.FleetVarNDESSCEPChallenge, fleet.FleetVarNDESSCEPProxyURL, fleet.FleetVarHostEndUserEmailIDP, - fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart, - fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname, fleet.FleetVarSCEPRenewalID, - fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform, - } -) - -type hostProfileUUID struct { - HostUUID string - ProfileUUID string +// TODO(HCA): Can we come up with a clearer name? This looks like any variables not in this slice is not supported, +// but that is not the case, digicert, custom scep, hydrant and smallstep are totally supported just in a different way (multiple CA's) +var fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{ + fleet.FleetVarNDESSCEPChallenge, fleet.FleetVarNDESSCEPProxyURL, fleet.FleetVarHostEndUserEmailIDP, + fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart, + fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname, fleet.FleetVarSCEPRenewalID, + fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform, } type getMDMAppleCommandResultsRequest struct { @@ -646,7 +630,7 @@ func additionalCustomSCEPValidation(contents string, customSCEPVars *CustomSCEPV } foundCAs = append(foundCAs, ca) } - if !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { + if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."} } if len(foundCAs) < len(customSCEPVars.CAs()) { @@ -699,7 +683,7 @@ func additionalSmallstepValidation(contents string, smallstepVars *SmallstepVars } foundCAs = append(foundCAs, ca) } - if !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { + if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."} } if len(foundCAs) < len(smallstepVars.CAs()) { @@ -821,7 +805,7 @@ func additionalNDESValidation(contents string, ndesVars *NDESVarsFound) error { return err } - if !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { + if !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleet.FleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) { return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."} } @@ -4902,16 +4886,6 @@ func ReconcileAppleDeclarations( return nil } -// install/removeTargets are maps from profileUUID -> command uuid and host -// UUIDs as the underlying MDM services are optimized to send one command to -// multiple hosts at the same time. Note that the same command uuid is used -// for all hosts in a given install/remove target operation. -type cmdTarget struct { - cmdUUID string - profIdent string - enrollmentIDs []string -} - // Number of hours to wait for a user enrollment to exist for a host after its // device enrollment. After that duration, the user-scoped profiles will be // delivered to the device-channel. @@ -5024,9 +4998,9 @@ func ReconcileAppleProfiles( hostProfilesToCleanup := []*fleet.MDMAppleProfilePayload{} // Index host profiles to install by host and profile UUID, for easier bulk error processing - hostProfilesToInstallMap := make(map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(toInstall)) + hostProfilesToInstallMap := make(map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(toInstall)) - installTargets, removeTargets := make(map[string]*cmdTarget), make(map[string]*cmdTarget) + installTargets, removeTargets := make(map[string]*fleet.CmdTarget), make(map[string]*fleet.CmdTarget) for _, p := range toInstall { if pp, ok := profileIntersection.GetMatchingProfileInCurrentState(p); ok { // if the profile was in any other status than `failed` @@ -5049,7 +5023,7 @@ func ReconcileAppleProfiles( Scope: pp.Scope, } hostProfiles = append(hostProfiles, hostProfile) - hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile continue } } @@ -5073,7 +5047,7 @@ func ReconcileAppleProfiles( Scope: p.Scope, } hostProfiles = append(hostProfiles, hostProfile) - hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile continue } @@ -5081,9 +5055,9 @@ func ReconcileAppleProfiles( target := installTargets[p.ProfileUUID] if target == nil { - target = &cmdTarget{ - cmdUUID: uuid.New().String(), - profIdent: p.ProfileIdentifier, + target = &fleet.CmdTarget{ + CmdUUID: uuid.New().String(), + ProfileIdentifier: p.ProfileIdentifier, } installTargets[p.ProfileUUID] = target } @@ -5120,9 +5094,9 @@ func ReconcileAppleProfiles( continue } - target.enrollmentIDs = append(target.enrollmentIDs, userEnrollmentID) + target.EnrollmentIDs = append(target.EnrollmentIDs, userEnrollmentID) } else { - target.enrollmentIDs = append(target.enrollmentIDs, p.HostUUID) + target.EnrollmentIDs = append(target.EnrollmentIDs, p.HostUUID) } toGetContents[p.ProfileUUID] = true @@ -5131,7 +5105,7 @@ func ReconcileAppleProfiles( HostUUID: p.HostUUID, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending, - CommandUUID: target.cmdUUID, + CommandUUID: target.CmdUUID, ProfileIdentifier: p.ProfileIdentifier, ProfileName: p.ProfileName, Checksum: p.Checksum, @@ -5139,7 +5113,7 @@ func ReconcileAppleProfiles( Scope: p.Scope, } hostProfiles = append(hostProfiles, hostProfile) - hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile } for _, p := range toRemove { @@ -5165,9 +5139,9 @@ func ReconcileAppleProfiles( target := removeTargets[p.ProfileUUID] if target == nil { - target = &cmdTarget{ - cmdUUID: uuid.New().String(), - profIdent: p.ProfileIdentifier, + target = &fleet.CmdTarget{ + CmdUUID: uuid.New().String(), + ProfileIdentifier: p.ProfileIdentifier, } removeTargets[p.ProfileUUID] = target } @@ -5184,9 +5158,9 @@ func ReconcileAppleProfiles( continue } - target.enrollmentIDs = append(target.enrollmentIDs, userEnrollmentID) + target.EnrollmentIDs = append(target.EnrollmentIDs, userEnrollmentID) } else { - target.enrollmentIDs = append(target.enrollmentIDs, p.HostUUID) + target.EnrollmentIDs = append(target.EnrollmentIDs, p.HostUUID) } hostProfiles = append(hostProfiles, &fleet.MDMAppleBulkUpsertHostProfilePayload{ @@ -5194,7 +5168,7 @@ func ReconcileAppleProfiles( HostUUID: p.HostUUID, OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending, - CommandUUID: target.cmdUUID, + CommandUUID: target.CmdUUID, ProfileIdentifier: p.ProfileIdentifier, ProfileName: p.ProfileName, Checksum: p.Checksum, @@ -5241,111 +5215,49 @@ func ReconcileAppleProfiles( return ctxerr.Wrap(ctx, err, "updating host profiles") } - // Grab the contents of all the profiles we need to install - profileUUIDs := make([]string, 0, len(toGetContents)) - for pUUID := range toGetContents { - profileUUIDs = append(profileUUIDs, pUUID) - } - profileContents, err := ds.GetMDMAppleProfilesContents(ctx, profileUUIDs) - if err != nil { - return ctxerr.Wrap(ctx, err, "get profile contents") - } - - groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting grouped certificate authorities") - } - - // Insert variables into profile contents of install targets. Variables may be host-specific. - err = preprocessProfileContents(ctx, appConfig, ds, - eeservice.NewSCEPConfigService(logger, nil), - digicert.NewService(digicert.WithLogger(logger)), - logger, installTargets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - if err != nil { - return err - } - - // Find the profiles containing secret variables. - profilesWithSecrets, err := findProfilesWithSecrets(ctx, logger, installTargets, profileContents) + enqueueResult, err := apple_mdm.ProcessAndEnqueueProfiles( + ctx, + ds, + logger, + appConfig, + commander, + installTargets, + removeTargets, + hostProfilesToInstallMap, + userEnrollmentsToHostUUIDsMap, + ) if err != nil { - return err - } - - type remoteResult struct { - Err error - CmdUUID string - } - - // Send the install/remove commands for each profile. - var wgProd, wgCons sync.WaitGroup - ch := make(chan remoteResult) - - execCmd := func(profUUID string, target *cmdTarget, op fleet.MDMOperationType) { - defer wgProd.Done() - - var err error - switch op { - case fleet.MDMOperationTypeInstall: - if _, ok := profilesWithSecrets[profUUID]; ok { - err = commander.EnqueueCommandInstallProfileWithSecrets(ctx, target.enrollmentIDs, profileContents[profUUID], target.cmdUUID) - } else { - err = commander.InstallProfile(ctx, target.enrollmentIDs, profileContents[profUUID], target.cmdUUID) + // revert the status of all pending profiles to null so they get picked up again in the next cron run. + // this is fine to do as if we errored out, we only do that before sending a single command + for _, hp := range hostProfiles { + if hp.Status != nil && *hp.Status == fleet.MDMDeliveryPending { + hp.Status = nil + hp.CommandUUID = "" } - case fleet.MDMOperationTypeRemove: - err = commander.RemoveProfile(ctx, target.enrollmentIDs, target.profIdent, target.cmdUUID) } - - var e *apple_mdm.APNSDeliveryError - switch { - case errors.As(err, &e): - logger.DebugContext(ctx, "sending push notifications, profiles still enqueued", "details", err) - case err != nil: - logger.ErrorContext(ctx, fmt.Sprintf("enqueue command to %s profiles", op), "details", err) - ch <- remoteResult{err, target.cmdUUID} + if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, hostProfiles); err != nil { + return ctxerr.Wrap(ctx, err, "reverting host profiles after failed enqueue") } - } - for profUUID, target := range installTargets { - wgProd.Add(1) - go execCmd(profUUID, target, fleet.MDMOperationTypeInstall) - } - for profUUID, target := range removeTargets { - wgProd.Add(1) - go execCmd(profUUID, target, fleet.MDMOperationTypeRemove) + return ctxerr.Wrap(ctx, err, "processing and enqueuing profiles") } - // index the host profiles by cmdUUID, for ease of error processing in the - // consumer goroutine below. - hostProfsByCmdUUID := make(map[string][]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(installTargets)+len(removeTargets)) + // Build cmdUUIDβ†’hostProfiles index AFTER preprocessing has rewritten CommandUUIDs. + hostProfsByCmdUUID := make(map[string][]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(hostProfiles)) for _, hp := range hostProfiles { - hostProfsByCmdUUID[hp.CommandUUID] = append(hostProfsByCmdUUID[hp.CommandUUID], hp) + if hp.CommandUUID != "" { + hostProfsByCmdUUID[hp.CommandUUID] = append(hostProfsByCmdUUID[hp.CommandUUID], hp) + } } - // Grab all the failed deliveries and update the status so they're picked up - // again in the next run. - // - // Note that if the APNs push failed we won't try again, as the command was - // successfully enqueued, this is only to account for internal errors like DB - // failures. - failed := []*fleet.MDMAppleBulkUpsertHostProfilePayload{} - wgCons.Add(1) - go func() { - defer wgCons.Done() - - for resp := range ch { - hostProfs := hostProfsByCmdUUID[resp.CmdUUID] - for _, hp := range hostProfs { - // clear the command as it failed to enqueue, will need to emit a new command - hp.CommandUUID = "" - // set status to nil so it is retried on the next cron run - hp.Status = nil - failed = append(failed, hp) - } + // Revert failed deliveries so they're retried on the next cron run. + var failed []*fleet.MDMAppleBulkUpsertHostProfilePayload + for cmdUUID := range enqueueResult.FailedCmdUUIDs { + for _, hp := range hostProfsByCmdUUID[cmdUUID] { + hp.CommandUUID = "" + hp.Status = nil + failed = append(failed, hp) } - }() - - wgProd.Wait() - close(ch) // done sending at this point, this triggers end of for loop in consumer - wgCons.Wait() + } if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, failed); err != nil { return ctxerr.Wrap(ctx, err, "reverting status of failed profiles") @@ -5354,747 +5266,6 @@ func ReconcileAppleProfiles( return nil } -func findProfilesWithSecrets( - ctx context.Context, - logger *slog.Logger, - installTargets map[string]*cmdTarget, - profileContents map[string]mobileconfig.Mobileconfig, -) (map[string]struct{}, error) { - profilesWithSecrets := make(map[string]struct{}) - for profUUID := range installTargets { - p, ok := profileContents[profUUID] - if !ok { // Should never happen - logger.ErrorContext(ctx, "profile content not found in ReconcileAppleProfiles", "profile_uuid", profUUID) - continue - } - profileStr := string(p) - vars := fleet.ContainsPrefixVars(profileStr, fleet.ServerSecretPrefix) - if len(vars) > 0 { - profilesWithSecrets[profUUID] = struct{}{} - } - } - return profilesWithSecrets, nil -} - -func preprocessProfileContents( - ctx context.Context, - appConfig *fleet.AppConfig, - ds fleet.Datastore, - scepConfig fleet.SCEPConfigService, - digiCertService fleet.DigiCertService, - logger *slog.Logger, - targets map[string]*cmdTarget, - profileContents map[string]mobileconfig.Mobileconfig, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - groupedCAs *fleet.GroupedCertificateAuthorities, -) error { - // This method replaces Fleet variables ($FLEET_VAR_) in the profile - // contents, generating a unique profile for each host. For a 2KB profile and - // 30K hosts, this method may generate ~60MB of profile data in memory. - - var ( - // Copy of NDES SCEP config which will contain unencrypted password, if needed - ndesConfig *fleet.NDESSCEPProxyCA - digiCertCAs map[string]*fleet.DigiCertCA - customSCEPCAs map[string]*fleet.CustomSCEPProxyCA - smallstepCAs map[string]*fleet.SmallstepSCEPProxyCA - ) - - // this is used to cache the host ID corresponding to the UUID, so we don't - // need to look it up more than once per host. - hostIDForUUIDCache := make(map[string]uint) - - var addedTargets map[string]*cmdTarget - for profUUID, target := range targets { - contents, ok := profileContents[profUUID] - if !ok { - // This should never happen - continue - } - - // Check if Fleet variables are present. - contentsStr := string(contents) - fleetVars := variables.Find(contentsStr) - if len(fleetVars) == 0 { - continue - } - - var variablesUpdatedAt *time.Time - - // Do common validation that applies to all hosts in the target - valid := true - // Check if there are any CA variables first so that if a non-CA variable causes - // preprocessing to fail, we still set the variablesUpdatedAt timestamp so that - // validation works as expected - // In the future we should expand variablesUpdatedAt logic to include non-CA variables as - // well - for _, fleetVar := range fleetVars { - if fleetVar == string(fleet.FleetVarSCEPRenewalID) || - fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL) || fleetVar == string(fleet.FleetVarHostUUID) || - strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) || - strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) || - strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) { - // Give a few minutes leeway to account for clock skew - variablesUpdatedAt = ptr.Time(time.Now().UTC().Add(-3 * time.Minute)) - break - } - } - - initialFleetVarLoop: - for _, fleetVar := range fleetVars { - switch { - case fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL): - configured, err := isNDESSCEPConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, target) - if err != nil { - return ctxerr.Wrap(ctx, err, "checking NDES SCEP configuration") - } - if !configured { - valid = false - break initialFleetVarLoop - } - - case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP) || fleetVar == string(fleet.FleetVarHostHardwareSerial) || fleetVar == string(fleet.FleetVarHostPlatform) || - fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) || - fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || fleetVar == string(fleet.FleetVarSCEPRenewalID) || - fleetVar == string(fleet.FleetVarHostEndUserIDPFullname) || fleetVar == string(fleet.FleetVarHostUUID): - // No extra validation needed for these variables - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)): - var caName string - if strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) - } else { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) - } - if digiCertCAs == nil { - digiCertCAs = make(map[string]*fleet.DigiCertCA) - } - configured, err := isDigiCertConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, digiCertCAs, profUUID, target, caName, fleetVar) - if err != nil { - return ctxerr.Wrap(ctx, err, "checking DigiCert configuration") - } - if !configured { - valid = false - break initialFleetVarLoop - } - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)): - var caName string - if strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) - } else { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) - } - if customSCEPCAs == nil { - customSCEPCAs = make(map[string]*fleet.CustomSCEPProxyCA) - for _, ca := range groupedCAs.CustomScepProxy { - customSCEPCAs[ca.Name] = &ca - } - } - err := profiles.IsCustomSCEPConfigured(ctx, customSCEPCAs, caName, fleetVar, func(errMsg string) error { - _, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, errMsg, ptr.Time(time.Now().UTC())) - return err - }) - if err != nil { - valid = false - break initialFleetVarLoop - } - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)): - if smallstepCAs == nil { - smallstepCAs = make(map[string]*fleet.SmallstepSCEPProxyCA) - } - var caName string - if strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) - } else { - caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) - } - configured, err := isSmallstepSCEPConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, smallstepCAs, profUUID, target, caName, - fleetVar) - if err != nil { - return ctxerr.Wrap(ctx, err, "checking Smallstep SCEP configuration") - } - if !configured { - valid = false - break initialFleetVarLoop - } - - default: - // Otherwise, error out since this variable is unknown - detail := fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.", - fleetVar) - _, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, detail, variablesUpdatedAt) - if err != nil { - return err - } - valid = false - } - } - if !valid { - // We marked the profile as failed, so we will not do any additional processing on it - delete(targets, profUUID) - continue - } - - // Currently, all supported Fleet variables are unique per host, so we split the profile into multiple profiles. - // We generate a new temporary profileUUID which is currently only used to install the profile. - // The profileUUID in host_mdm_apple_profiles is still the original profileUUID. - // We also generate a new commandUUID which is used to install the profile via nano_commands table. - if addedTargets == nil { - addedTargets = make(map[string]*cmdTarget, 1) - } - // We store the timestamp when the challenge was retrieved to know if it has expired. - var managedCertificatePayloads []*fleet.MDMManagedCertificate - // We need to update the profiles of each host with the new command UUID - profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.enrollmentIDs)) - for _, enrollmentID := range target.enrollmentIDs { - tempProfUUID := uuid.NewString() - // Use the same UUID for command UUID, which will be the primary key for nano_commands - tempCmdUUID := tempProfUUID - profile, ok := getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID) - if !ok { // Should never happen - continue - } - // Fetch the host UUID, which may not be the same as the Enrollment ID, from the profile - hostUUID := profile.HostUUID - - // some variables need more information about the host; build a skeleton host and hydrate if we need more info - hostLite := fleet.Host{UUID: hostUUID} - onMismatchedHostCount := func(hostCount int) error { - return ctxerr.Wrap(ctx, ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostLite.UUID, - Status: &fleet.MDMDeliveryFailed, - Detail: fmt.Sprintf("Unexpected number of hosts (%d) for UUID %s.", hostCount, hostLite.UUID), - OperationType: fleet.MDMOperationTypeInstall, - }), "could not retrieve host by UUID for profile variable substitution") - } - - profile.CommandUUID = tempCmdUUID - profile.VariablesUpdatedAt = variablesUpdatedAt - - hostContents := contentsStr - failed := false - - fleetVarLoop: - for _, fleetVar := range fleetVars { - var err error - switch { - case fleetVar == string(fleet.FleetVarNDESSCEPChallenge): - if ndesConfig == nil { - ndesConfig = groupedCAs.NDESSCEP - } - logger.DebugContext(ctx, "fetching NDES challenge", "host_uuid", hostUUID, "profile_uuid", profUUID) - // Insert the SCEP challenge into the profile contents - challenge, err := scepConfig.GetNDESSCEPChallenge(ctx, *ndesConfig) - if err != nil { - detail := ndesChallengeErrorToDetail(err) - err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: detail, - OperationType: fleet.MDMOperationTypeInstall, - VariablesUpdatedAt: variablesUpdatedAt, - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for NDES SCEP challenge") - } - failed = true - break fleetVarLoop - } - payload := &fleet.MDMManagedCertificate{ - HostUUID: hostUUID, - ProfileUUID: profUUID, - ChallengeRetrievedAt: ptr.Time(time.Now()), - Type: fleet.CAConfigNDES, - CAName: "NDES", - } - managedCertificatePayloads = append(managedCertificatePayloads, payload) - - hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarNDESSCEPChallengeRegexp, hostContents, challenge) - - case fleetVar == string(fleet.FleetVarNDESSCEPProxyURL): - // Insert the SCEP URL into the profile contents - hostContents = profiles.ReplaceNDESSCEPProxyURLVariable(appConfig.MDMUrl(), hostUUID, profUUID, hostContents) - - case fleetVar == string(fleet.FleetVarSCEPRenewalID): - // Insert the SCEP renewal ID into the SCEP Payload CN or OU - fleetRenewalID := "fleet-" + profUUID - hostContents = profiles.ReplaceFleetVariableInXML(fleetVarSCEPRenewalIDRegexp, hostContents, fleetRenewalID) - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)): - replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(ctx, logger, fleetVar, customSCEPCAs, hostContents) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing custom SCEP challenge variable") - } - if !replacedVariable { - continue - } - hostContents = replacedContents - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)): - replacedContents, managedCertificate, replacedVariable, err := profiles.ReplaceCustomSCEPProxyURLVariable(ctx, logger, ds, appConfig, fleetVar, customSCEPCAs, hostContents, hostUUID, profUUID) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing custom SCEP proxy URL variable") - } - if !replacedVariable { - continue - } - hostContents = replacedContents - managedCertificatePayloads = append(managedCertificatePayloads, managedCertificate) - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)): - caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) - ca, ok := smallstepCAs[caName] - if !ok { - logger.ErrorContext(ctx, "Smallstep SCEP CA not found. "+ - "This error should never happen since we validated/populated CAs earlier", "ca_name", caName) - continue - } - logger.DebugContext(ctx, "fetching Smallstep SCEP challenge", "host_uuid", hostUUID, "profile_uuid", profUUID) - challenge, err := scepConfig.GetSmallstepSCEPChallenge(ctx, *ca) - if err != nil { - detail := fmt.Sprintf("Fleet couldn't populate $FLEET_VAR_%s. %s", fleet.FleetVarSmallstepSCEPChallengePrefix, err.Error()) - err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: detail, - OperationType: fleet.MDMOperationTypeInstall, - VariablesUpdatedAt: variablesUpdatedAt, - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for Smallstep SCEP challenge") - } - failed = true - break fleetVarLoop - } - logger.InfoContext(ctx, "retrieved SCEP challenge from Smallstep", "host_uuid", hostUUID, "profile_uuid", profUUID) - - payload := &fleet.MDMManagedCertificate{ - HostUUID: hostUUID, - ProfileUUID: profUUID, - ChallengeRetrievedAt: ptr.Time(time.Now()), - Type: fleet.CAConfigSmallstep, - CAName: caName, - } - managedCertificatePayloads = append(managedCertificatePayloads, payload) - hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPChallengePrefix), ca.Name, hostContents, challenge) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP challenge variable") - } - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)): - // Insert the SCEP URL into the profile contents - caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) - proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath, - url.PathEscape(fmt.Sprintf("%s,%s,%s", hostUUID, profUUID, caName))) - hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPProxyURLPrefix), caName, hostContents, proxyURL) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP URL variable") - } - - case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP): - email, ok, err := getFirstIDPEmail(ctx, ds, target, hostUUID) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting IDP email") - } - if !ok { - failed = true - break fleetVarLoop - } - hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, hostContents, email) - - case fleetVar == string(fleet.FleetVarHostHardwareSerial): - hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting host hardware serial") - } - if !ok { - failed = true - break fleetVarLoop - } - hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, hostContents, hostLite.HardwareSerial) - case fleetVar == string(fleet.FleetVarHostPlatform): - hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "getting host platform") - } - if !ok { - failed = true - break fleetVarLoop - } - platform := hostLite.Platform - if platform == "darwin" { - platform = "macos" - } - - hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, hostContents, platform) - case fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) || - fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || - fleetVar == string(fleet.FleetVarHostEndUserIDPFullname): - replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserIDPVariables(ctx, ds, fleetVar, hostContents, hostUUID, hostIDForUUIDCache, func(errMsg string) error { - err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: errMsg, - OperationType: fleet.MDMOperationTypeInstall, - }) - return err - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing host end user IDP variables") - } - if !replacedVariable { - failed = true - break fleetVarLoop - } - - hostContents = replacedContents - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)): - // We will replace the password when we populate the certificate data - - case fleetVar == string(fleet.FleetVarHostUUID): - hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostUUIDRegexp, hostContents, hostUUID) - - case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)): - caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) - ca, ok := digiCertCAs[caName] - if !ok { - logger.ErrorContext(ctx, "Custom DigiCert CA not found. "+ - "This error should never happen since we validated/populated CAs earlier", "ca_name", caName) - continue - } - caCopy := *ca - // Deep copy the UPN slice to prevent cross-host contamination: a - // shallow copy shares the backing array, so in-place substitutions for - // one host would corrupt the cached CA used by subsequent hosts. - caCopy.CertificateUserPrincipalNames = slices.Clone(ca.CertificateUserPrincipalNames) - - // Populate Fleet vars in the CA fields - caVarsCache := make(map[string]string) - - ok, err := replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateCommonName, onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") - } - if !ok { - failed = true - break fleetVarLoop - } - ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateSeatID, onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") - } - if !ok { - failed = true - break fleetVarLoop - } - if len(caCopy.CertificateUserPrincipalNames) > 0 { - for i := range caCopy.CertificateUserPrincipalNames { - ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateUserPrincipalNames[i], onMismatchedHostCount) - if err != nil { - return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name") - } - if !ok { - failed = true - break fleetVarLoop - } - } - } - - cert, err := digiCertService.GetCertificate(ctx, caCopy) - if err != nil { - detail := fmt.Sprintf("Couldn't get certificate from DigiCert for %s. %s", caCopy.Name, err) - err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: detail, - OperationType: fleet.MDMOperationTypeInstall, - VariablesUpdatedAt: variablesUpdatedAt, - }) - if err != nil { - return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for DigiCert") - } - failed = true - break fleetVarLoop - } - hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertDataPrefix), caName, hostContents, - base64.StdEncoding.EncodeToString(cert.PfxData)) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert data") - } - hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertPasswordPrefix), caName, hostContents, cert.Password) - if err != nil { - return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert password") - } - managedCertificatePayloads = append(managedCertificatePayloads, &fleet.MDMManagedCertificate{ - HostUUID: hostUUID, - ProfileUUID: profUUID, - NotValidBefore: &cert.NotValidBefore, - NotValidAfter: &cert.NotValidAfter, - Type: fleet.CAConfigDigiCert, - CAName: caName, - Serial: &cert.SerialNumber, - }) - - default: - // This was handled in the above switch statement, so we should never reach this case - } - } - if !failed { - addedTargets[tempProfUUID] = &cmdTarget{ - cmdUUID: tempCmdUUID, - profIdent: target.profIdent, - enrollmentIDs: []string{enrollmentID}, - } - profileContents[tempProfUUID] = mobileconfig.Mobileconfig(hostContents) - profilesToUpdate = append(profilesToUpdate, profile) - } - } - // Update profiles with the new command UUID - if len(profilesToUpdate) > 0 { - if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { - return ctxerr.Wrap(ctx, err, "updating host profiles") - } - } - if len(managedCertificatePayloads) != 0 { - // TODO: We could filter out failed profiles, but at the moment we don't, see Windows impl. for how it's done there. - err := ds.BulkUpsertMDMManagedCertificates(ctx, managedCertificatePayloads) - if err != nil { - return ctxerr.Wrap(ctx, err, "updating managed certificates") - } - } - // Remove the parent target, since we will use host-specific targets - delete(targets, profUUID) - } - if len(addedTargets) > 0 { - // Add the new host-specific targets to the original targets map - for profUUID, target := range addedTargets { - targets[profUUID] = target - } - } - return nil -} - -func getFirstIDPEmail(ctx context.Context, ds fleet.Datastore, target *cmdTarget, hostUUID string) (string, bool, error) { - // Insert the end user email IDP into the profile contents - emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts) - if err != nil { - // This is a server error, so we exit. - return "", false, ctxerr.Wrap(ctx, err, "getting host emails") - } - if len(emails) == 0 { - // We couldn't retrieve the end user email IDP, so mark the profile as failed with additional detail. - err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{ - CommandUUID: target.cmdUUID, - HostUUID: hostUUID, - Status: &fleet.MDMDeliveryFailed, - Detail: fmt.Sprintf("There is no IdP email for this host. "+ - "Fleet couldn't populate $FLEET_VAR_%s. "+ - "[Learn more](https://fleetdm.com/learn-more-about/idp-email)", - fleet.FleetVarHostEndUserEmailIDP), - OperationType: fleet.MDMOperationTypeInstall, - }) - if err != nil { - return "", false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user email IdP") - } - return "", false, nil - } - return emails[0], true, nil -} - -func replaceFleetVarInItem(ctx context.Context, ds fleet.Datastore, target *cmdTarget, hostLite fleet.Host, caVarsCache map[string]string, item *string, onMismatchedHostCount func(int) error) (bool, error) { - caFleetVars := variables.Find(*item) - for _, caVar := range caFleetVars { - switch caVar { - case string(fleet.FleetVarHostEndUserEmailIDP): - email, ok := caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] - if !ok { - var err error - email, ok, err = getFirstIDPEmail(ctx, ds, target, hostLite.UUID) - if err != nil { - return false, ctxerr.Wrap(ctx, err, "getting IDP email") - } - if !ok { - return false, nil - } - caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] = email - } - *item = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, *item, email) - case string(fleet.FleetVarHostHardwareSerial): - hardwareSerial, ok := caVarsCache[string(fleet.FleetVarHostHardwareSerial)] - if !ok { - var err error - hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) - if err != nil { - return false, ctxerr.Wrap(ctx, err, "getting host hardware serial") - } - if !ok { - return false, nil - } - hardwareSerial = hostLite.HardwareSerial - caVarsCache[string(fleet.FleetVarHostHardwareSerial)] = hostLite.HardwareSerial - } - *item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, *item, hardwareSerial) - case string(fleet.FleetVarHostPlatform): - platform, ok := caVarsCache[string(fleet.FleetVarHostPlatform)] - if !ok { - var err error - hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount) - if err != nil { - return false, ctxerr.Wrap(ctx, err, "getting host hardware serial") - } - if !ok { - return false, nil - } - platform = hostLite.Platform - if platform == "darwin" { - platform = "macos" - } - - caVarsCache[string(fleet.FleetVarHostPlatform)] = platform - } - *item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, *item, platform) - default: - // We should not reach this since we validated the variables when saving app config - } - } - return true, nil -} - -func isDigiCertConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - existingDigiCertCAs map[string]*fleet.DigiCertCA, profUUID string, target *cmdTarget, caName string, fleetVar string, -) (bool, error) { - if !license.IsPremium(ctx) { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "DigiCert integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) - } - if _, ok := existingDigiCertCAs[caName]; ok { - return true, nil - } - configured := false - var digiCertCA *fleet.DigiCertCA - if len(groupedCAs.DigiCert) > 0 { - for _, ca := range groupedCAs.DigiCert { - if ca.Name == caName { - digiCertCA = &ca - configured = true - break - } - } - } - if !configured || digiCertCA == nil { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, - fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC())) - } - - existingDigiCertCAs[caName] = digiCertCA - return true, nil -} - -func isNDESSCEPConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, userEnrollmentsToHostUUIDsMap map[string]string, profUUID string, target *cmdTarget, -) (bool, error) { - if !license.IsPremium(ctx) { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "NDES SCEP Proxy requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) - } - if groupedCAs.NDESSCEP == nil { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, - "NDES SCEP Proxy is not configured. Please configure in Settings > Integrations > Certificates.", ptr.Time(time.Now().UTC())) - } - return true, nil -} - -func isSmallstepSCEPConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - existingSmallstepSCEPCAs map[string]*fleet.SmallstepSCEPProxyCA, profUUID string, target *cmdTarget, caName string, fleetVar string, -) (bool, error) { - if !license.IsPremium(ctx) { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "Smallstep SCEP integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC())) - } - if _, ok := existingSmallstepSCEPCAs[caName]; ok { - return true, nil - } - configured := false - var scepCA *fleet.SmallstepSCEPProxyCA - if len(groupedCAs.Smallstep) > 0 { - for _, ca := range groupedCAs.Smallstep { - if ca.Name == caName { - scepCA = &ca - configured = true - break - } - } - } - if !configured || scepCA == nil { - return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, - fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC())) - } - - existingSmallstepSCEPCAs[caName] = scepCA - return true, nil -} - -func getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - enrollmentID, - profUUID string, -) (*fleet.MDMAppleBulkUpsertHostProfilePayload, bool) { - profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: enrollmentID, ProfileUUID: profUUID}] - if !ok { - var hostUUID string - // If sending to the user channel the enrollmentID will have to be mapped back to the host UUID. - hostUUID, ok = userEnrollmentsToHostUUIDsMap[enrollmentID] - if ok { - profile, ok = hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] - } - } - return profile, ok -} - -func markProfilesFailed( - ctx context.Context, - ds fleet.Datastore, - target *cmdTarget, - hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, - userEnrollmentsToHostUUIDsMap map[string]string, - profUUID string, - detail string, - variablesUpdatedAt *time.Time, -) (bool, error) { - profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.enrollmentIDs)) - for _, enrollmentID := range target.enrollmentIDs { - profile, ok := getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID) - if !ok { - // If sending to the user channel the enrollmentID will have to be mapped back to the host UUID. - hostUUID, ok := userEnrollmentsToHostUUIDsMap[enrollmentID] - if ok { - profile, ok = hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}] - } - if !ok { - continue - } - } - profile.Status = &fleet.MDMDeliveryFailed - profile.Detail = detail - profile.VariablesUpdatedAt = variablesUpdatedAt - profilesToUpdate = append(profilesToUpdate, profile) - } - if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil { - return false, ctxerr.Wrap(ctx, err, "marking host profiles failed") - } - return false, nil -} - // scepCertRenewalThresholdDays defines the number of days before a SCEP // certificate must be renewed. const scepCertRenewalThresholdDays = 180 diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index ea7ea5594f9..cf7266e562a 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -27,8 +27,6 @@ import ( "testing" "time" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" - "github.com/fleetdm/fleet/v4/ee/server/service/digicert" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" @@ -47,10 +45,8 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service" "github.com/fleetdm/fleet/v4/server/mock" - digicert_mock "github.com/fleetdm/fleet/v4/server/mock/digicert" mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" - scep_mock "github.com/fleetdm/fleet/v4/server/mock/scep" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/redis_key_value" "github.com/fleetdm/fleet/v4/server/test" @@ -3475,7 +3471,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { failedCall = false failedCheck = func(payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) { failedCount++ - require.Len(t, payload, 0) + require.Len(t, payload, 8) } enqueueFailForOp = "" newContents := "$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP @@ -3487,7 +3483,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { return nil, errors.New("GetHostEmailsFuncError") } - err := ReconcileAppleProfiles(ctx, ds, cmdr, slog.New(slog.DiscardHandler)) + err := ReconcileAppleProfiles(ctx, ds, cmdr, slog.New(slog.Default().Handler())) assert.ErrorContains(t, err, "GetHostEmailsFuncError") // checkAndReset(t, true, &ds.GetAllCertificateAuthoritiesFuncInvoked) checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallAndRemoveFuncInvoked) @@ -3563,617 +3559,6 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) { }) } -func TestPreprocessProfileContents(t *testing.T) { - ctx := context.Background() - logger := slog.New(slog.DiscardHandler) - appCfg := &fleet.AppConfig{} - appCfg.ServerSettings.ServerURL = "https://test.example.com" - appCfg.MDM.EnabledAndConfigured = true - ds := new(mock.Store) - - // No-op - svc := eeservice.NewSCEPConfigService(logger, nil) - digiCertService := digicert.NewService(digicert.WithLogger(logger)) - err := preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, nil, nil, nil, nil, nil) - require.NoError(t, err) - - hostUUID := "host-1" - cmdUUID := "cmd-1" - var targets map[string]*cmdTarget - populateTargets := func() { - targets = map[string]*cmdTarget{ - "p1": {cmdUUID: cmdUUID, profIdent: "com.add.profile", enrollmentIDs: []string{hostUUID}}, - } - } - hostProfilesToInstallMap := make(map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, 1) - hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: "p1"}] = &fleet.MDMAppleBulkUpsertHostProfilePayload{ - ProfileUUID: "p1", - ProfileIdentifier: "com.add.profile", - HostUUID: hostUUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &fleet.MDMDeliveryPending, - CommandUUID: cmdUUID, - Scope: fleet.PayloadScopeSystem, - } - userEnrollmentsToHostUUIDsMap := make(map[string]string) - populateTargets() - profileContents := map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), - } - - var updatedPayload *fleet.MDMAppleBulkUpsertHostProfilePayload - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - require.Len(t, payload, 1) - updatedPayload = payload[0] - for _, p := range payload { - require.NotNil(t, p.Status) - assert.Equal(t, fleet.MDMDeliveryFailed, *p.Status) - assert.Equal(t, cmdUUID, p.CommandUUID) - assert.Equal(t, hostUUID, p.HostUUID) - assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) - assert.Equal(t, fleet.PayloadScopeSystem, p.Scope) - } - return nil - } - // Can't use NDES SCEP proxy with free tier - ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierFree}) - err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) - require.NoError(t, err) - require.NotNil(t, updatedPayload) - assert.Contains(t, updatedPayload.Detail, "Premium license") - assert.Empty(t, targets) - - // Can't use NDES SCEP proxy without it being configured - ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) - updatedPayload = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, &fleet.GroupedCertificateAuthorities{}) - require.NoError(t, err) - require.NotNil(t, updatedPayload) - assert.Contains(t, updatedPayload.Detail, "not configured") - assert.NotNil(t, updatedPayload.VariablesUpdatedAt) - assert.Empty(t, targets) - - // Unknown variable - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_BOZO"), - } - updatedPayload = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) - require.NoError(t, err) - require.NotNil(t, updatedPayload) - assert.Contains(t, updatedPayload.Detail, "FLEET_VAR_BOZO") - assert.Empty(t, targets) - - ndesPassword := "test-password" - ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, - assetNames []fleet.MDMAssetName, _ sqlx.QueryerContext, - ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { - return map[fleet.MDMAssetName]fleet.MDMConfigAsset{ - fleet.MDMAssetNDESPassword: {Value: []byte(ndesPassword)}, - }, nil - } - - ds.BulkUpsertMDMAppleHostProfilesFunc = nil - var updatedProfile *fleet.HostMDMAppleProfile - ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { - updatedProfile = profile - require.NotNil(t, updatedProfile.Status) - assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) - assert.Equal(t, cmdUUID, updatedProfile.CommandUUID) - assert.Equal(t, hostUUID, updatedProfile.HostUUID) - assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) - return nil - } - ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { - assert.Empty(t, payload) - return nil - } - - adminUrl := "https://example.com" - username := "admin" - password := "test-password" - groupedCAs := &fleet.GroupedCertificateAuthorities{ - NDESSCEP: &fleet.NDESSCEPProxyCA{ - URL: "https://test-example.com", - AdminURL: adminUrl, - Username: username, - Password: password, - }, - } - - // Could not get NDES SCEP challenge - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPChallenge), - } - scepConfig := &scep_mock.SCEPConfigService{} - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return "", eeservice.NewNDESInvalidError("NDES error") - } - updatedProfile = nil - populateTargets() - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - assert.Empty(t, payload) // no profiles to update since FLEET VAR could not be populated - return nil - } - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) - assert.Contains(t, updatedProfile.Detail, "update credentials") - assert.NotNil(t, updatedProfile.VariablesUpdatedAt) - assert.Empty(t, targets) - - // Password cache full - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return "", eeservice.NewNDESPasswordCacheFullError("NDES error") - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) - assert.Contains(t, updatedProfile.Detail, "cached passwords") - assert.NotNil(t, updatedProfile.VariablesUpdatedAt) - assert.Empty(t, targets) - - // Insufficient permissions - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return "", eeservice.NewNDESInsufficientPermissionsError("NDES error") - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) - assert.Contains(t, updatedProfile.Detail, "does not have sufficient permissions") - assert.NotNil(t, updatedProfile.VariablesUpdatedAt) - assert.Empty(t, targets) - - // Other NDES challenge error - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return "", errors.New("NDES error") - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarNDESSCEPChallenge) - assert.NotContains(t, updatedProfile.Detail, "cached passwords") - assert.NotContains(t, updatedProfile.Detail, "update credentials") - assert.NotNil(t, updatedProfile.VariablesUpdatedAt) - assert.Empty(t, targets) - - // NDES challenge - challenge := "ndes-challenge" - scepConfig.GetNDESSCEPChallengeFunc = func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error) { - assert.Equal(t, ndesPassword, proxy.Password) - return challenge, nil - } - updatedProfile = nil - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - for _, p := range payload { - assert.NotEqual(t, cmdUUID, p.CommandUUID) - } - return nil - } - populateTargets() - ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { - require.Len(t, payload, 1) - assert.NotNil(t, payload[0].ChallengeRetrievedAt) - return nil - } - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, challenge, string(profileContents[profUUID])) - } - - // NDES SCEP proxy URL - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), - } - expectedURL := "https://test.example.com" + apple_mdm.SCEPProxyPath + url.QueryEscape(fmt.Sprintf("%s,%s,NDES", hostUUID, "p1")) - updatedProfile = nil - populateTargets() - ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { - assert.Empty(t, payload) - return nil - } - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, expectedURL, string(profileContents[profUUID])) - } - - // No IdP email found - ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { - return nil, nil - } - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "FLEET_VAR_"+fleet.FleetVarHostEndUserEmailIDP) - assert.Contains(t, updatedProfile.Detail, "no IdP email") - assert.Empty(t, targets) - - // IdP email found - email := "user@example.com" - ds.GetHostEmailsFunc = func(ctx context.Context, hostUUID string, source string) ([]string, error) { - return []string{email}, nil - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, email, string(profileContents[profUUID])) - } - - // Hardware serial - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return []*fleet.Host{ - {HardwareSerial: "serial1"}, - }, nil - } - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostHardwareSerial), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, "serial1", string(profileContents[profUUID])) - } - - // Hardware serial fail - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return nil, nil - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "Unexpected number of hosts (0) for UUID") - assert.Empty(t, targets) - - // Host UUID - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarHostUUID), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - require.NotEmpty(t, targets) - assert.Len(t, targets, 1) - for profUUID, target := range targets { - assert.NotEqual(t, profUUID, "p1") // new temporary UUID generated for specific host - assert.NotEqual(t, cmdUUID, target.cmdUUID) - assert.Equal(t, []string{hostUUID}, target.enrollmentIDs) - assert.Equal(t, hostUUID, string(profileContents[profUUID])) - } - - // Host Platform - macOS (darwin -> macos) - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return []*fleet.Host{ - {ID: 1, UUID: hostUUID, Platform: "darwin", HardwareSerial: "serial1"}, - }, nil - } - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_HOST_PLATFORM"), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Len(t, targets, 1) - for profUUID := range targets { - assert.Equal(t, "macos", string(profileContents[profUUID])) - } - - // Host Platform - iOS - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return []*fleet.Host{ - {ID: 1, UUID: hostUUID, Platform: "ios", HardwareSerial: "serial1"}, - }, nil - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Len(t, targets, 1) - for profUUID := range targets { - assert.Equal(t, "ios", string(profileContents[profUUID])) - } - - // Host Platform fail - host not found - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return nil, nil - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotNil(t, updatedProfile) - assert.Contains(t, updatedProfile.Detail, "Unexpected number of hosts (0) for UUID") - assert.Empty(t, targets) - - // Host Platform with Hardware Serial - both variables in profile - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - assert.Equal(t, []string{hostUUID}, uuids) - return []*fleet.Host{ - {ID: 1, UUID: hostUUID, Platform: "darwin", HardwareSerial: "serial123"}, - }, nil - } - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_HOST_PLATFORM $FLEET_VAR_HOST_HARDWARE_SERIAL"), - } - updatedProfile = nil - populateTargets() - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - assert.Nil(t, updatedProfile) - assert.Len(t, targets, 1) - for profUUID := range targets { - assert.Equal(t, "macos serial123", string(profileContents[profUUID])) - } - - // multiple profiles, multiple hosts - populateTargets = func() { - targets = map[string]*cmdTarget{ - "p1": {cmdUUID: cmdUUID, profIdent: "com.add.profile", enrollmentIDs: []string{hostUUID, "host-2"}}, // fails - "p2": {cmdUUID: cmdUUID, profIdent: "com.add.profile2", enrollmentIDs: []string{hostUUID, "host-3"}}, // works - "p3": {cmdUUID: cmdUUID, profIdent: "com.add.profile3", enrollmentIDs: []string{hostUUID, "host-4"}}, // no variables - } - } - populateTargets() - groupedCAs.NDESSCEP = nil - profileContents = map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + fleet.FleetVarNDESSCEPProxyURL), - "p2": []byte("$FLEET_VAR_" + fleet.FleetVarHostEndUserEmailIDP), - "p3": []byte("no variables"), - } - addProfileToInstall := func(hostUUID, profileUUID, profileIdentifier string) { - hostProfilesToInstallMap[hostProfileUUID{ - HostUUID: hostUUID, - ProfileUUID: profileUUID, - }] = &fleet.MDMAppleBulkUpsertHostProfilePayload{ - ProfileUUID: profileUUID, - ProfileIdentifier: profileIdentifier, - HostUUID: hostUUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &fleet.MDMDeliveryPending, - CommandUUID: cmdUUID, - Scope: fleet.PayloadScopeSystem, - } - } - addProfileToInstall(hostUUID, "p1", "com.add.profile") - addProfileToInstall("host-2", "p1", "com.add.profile") - addProfileToInstall(hostUUID, "p2", "com.add.profile2") - addProfileToInstall("host-3", "p2", "com.add.profile2") - addProfileToInstall(hostUUID, "p3", "com.add.profile3") - addProfileToInstall("host-4", "p3", "com.add.profile3") - expectedHostsToFail := []string{hostUUID, "host-2", "host-3"} - ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { - updatedProfile = profile - require.NotNil(t, updatedProfile.Status) - assert.Equal(t, fleet.MDMDeliveryFailed, *updatedProfile.Status) - assert.NotEqual(t, cmdUUID, updatedProfile.CommandUUID) - assert.Contains(t, expectedHostsToFail, updatedProfile.HostUUID) - assert.Equal(t, fleet.MDMOperationTypeInstall, updatedProfile.OperationType) - return nil - } - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - for _, p := range payload { - require.NotNil(t, p.Status) - if fleet.MDMDeliveryFailed == *p.Status { - assert.Equal(t, cmdUUID, p.CommandUUID) - } else { - assert.NotEqual(t, cmdUUID, p.CommandUUID) - } - assert.Equal(t, fleet.MDMOperationTypeInstall, p.OperationType) - } - return nil - } - err = preprocessProfileContents(ctx, appCfg, ds, scepConfig, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs) - require.NoError(t, err) - require.NotEmpty(t, targets) - assert.Len(t, targets, 3) - assert.Nil(t, targets["p1"]) // error - assert.Nil(t, targets["p2"]) // renamed - assert.NotNil(t, targets["p3"]) // normal, no variables - for profUUID, target := range targets { - assert.Contains(t, [][]string{{hostUUID}, {"host-3"}, {hostUUID, "host-4"}}, target.enrollmentIDs) - if profUUID == "p3" { - assert.Equal(t, cmdUUID, target.cmdUUID) - } else { - assert.NotEqual(t, cmdUUID, target.cmdUUID) - } - assert.Contains(t, []string{email, "no variables"}, string(profileContents[profUUID])) - } -} - -// TestPreprocessProfileContentsDigiCertUPNMultiHost is a regression test for -// https://github.com/fleetdm/fleet/issues/39324. When the same DigiCert CA is -// used for multiple hosts in a single batch, the CertificateUserPrincipalNames -// slice was shared via a shallow copy. In-place variable substitution for Host 1 -// corrupted the cached CA entry, so Host 2 and later hosts received Host 1's -// substituted UPN instead of their own. -func TestPreprocessProfileContentsDigiCertUPNIsUniqueForMultipleHosts(t *testing.T) { - ctx := context.Background() - ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: fleet.TierPremium}) - logger := slog.New(slog.DiscardHandler) - appCfg := &fleet.AppConfig{} - appCfg.ServerSettings.ServerURL = "https://test.example.com" - appCfg.MDM.EnabledAndConfigured = true - ds := new(mock.Store) - - svc := eeservice.NewSCEPConfigService(logger, nil) - - const caName = "myCA" - const host1UUID = "host-uuid-1" - const host2UUID = "host-uuid-2" - const host1Serial = "SERIAL-AAA" - const host2Serial = "SERIAL-BBB" - const cmdUUID = "cmd-uuid-1" - - // Track which UPNs were sent to GetCertificate per host. - upnByHostUUID := make(map[string]string) - - mockDigiCert := &digicert_mock.Service{} - mockDigiCert.GetCertificateFunc = func(ctx context.Context, config fleet.DigiCertCA) (*fleet.DigiCertCertificate, error) { - // The UPN in config should have been substituted with each host's own - // hardware serial. Record it so we can assert correctness later. - require.Len(t, config.CertificateUserPrincipalNames, 1) - upn := config.CertificateUserPrincipalNames[0] - // Determine which host this call is for by checking which serial is in the UPN. - switch { - case strings.Contains(upn, host1Serial): - upnByHostUUID[host1UUID] = upn - case strings.Contains(upn, host2Serial): - upnByHostUUID[host2UUID] = upn - default: - t.Errorf("GetCertificate called with unexpected UPN %q", upn) - } - now := time.Now() - return &fleet.DigiCertCertificate{ - PfxData: []byte("fake-pfx"), - Password: "fake-password", - NotValidBefore: now, - NotValidAfter: now.Add(365 * 24 * time.Hour), - SerialNumber: upn, // reuse upn as serial for easy tracing - }, nil - } - - // Both hosts share the same profile UUID but are separate enrollment IDs. - targets := map[string]*cmdTarget{ - "p1": { - cmdUUID: cmdUUID, - profIdent: "com.apple.security.pkcs12", - enrollmentIDs: []string{host1UUID, host2UUID}, - }, - } - - pending := fleet.MDMDeliveryPending - hostProfilesToInstallMap := map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload{ - {HostUUID: host1UUID, ProfileUUID: "p1"}: { - ProfileUUID: "p1", - ProfileIdentifier: "com.apple.security.pkcs12", - HostUUID: host1UUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &pending, - CommandUUID: cmdUUID, - Scope: fleet.PayloadScopeSystem, - }, - {HostUUID: host2UUID, ProfileUUID: "p1"}: { - ProfileUUID: "p1", - ProfileIdentifier: "com.apple.security.pkcs12", - HostUUID: host2UUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &pending, - CommandUUID: cmdUUID, - Scope: fleet.PayloadScopeSystem, - }, - } - - // Profile contains both DigiCert fleet variables. - profileContents := map[string]mobileconfig.Mobileconfig{ - "p1": []byte("$FLEET_VAR_" + string(fleet.FleetVarDigiCertPasswordPrefix) + caName + " $FLEET_VAR_" + string(fleet.FleetVarDigiCertDataPrefix) + caName), - } - - // DigiCert CA whose UPN contains the hardware serial variable. - groupedCAs := &fleet.GroupedCertificateAuthorities{ - DigiCert: []fleet.DigiCertCA{ - { - Name: caName, - URL: "https://digicert.example.com", - APIToken: "api_token", - ProfileID: "profile_id", - CertificateCommonName: "common_name", - CertificateUserPrincipalNames: []string{"$FLEET_VAR_" + string(fleet.FleetVarHostHardwareSerial) + "@example.com"}, - CertificateSeatID: "seat_id", - }, - }, - } - - // Mock datastore: return each host's own hardware serial when queried. - hostsByUUID := map[string]*fleet.Host{ - host1UUID: {ID: 1, UUID: host1UUID, HardwareSerial: host1Serial, Platform: "darwin"}, - host2UUID: {ID: 2, UUID: host2UUID, HardwareSerial: host2Serial, Platform: "darwin"}, - } - ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, _ fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { - var hosts []*fleet.Host - for _, uuid := range uuids { - if h, ok := hostsByUUID[uuid]; ok { - hosts = append(hosts, h) - } - } - return hosts, nil - } - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - return nil - } - ds.BulkUpsertMDMManagedCertificatesFunc = func(ctx context.Context, payload []*fleet.MDMManagedCertificate) error { - return nil - } - - err := preprocessProfileContents(ctx, appCfg, ds, svc, mockDigiCert, logger, targets, profileContents, hostProfilesToInstallMap, make(map[string]string), groupedCAs) - require.NoError(t, err) - - // Both hosts must have received GetCertificate calls with their own serial. - require.True(t, mockDigiCert.GetCertificateFuncInvoked, "GetCertificate was never called") - require.Contains(t, upnByHostUUID, host1UUID, "GetCertificate was not called for host 1") - require.Contains(t, upnByHostUUID, host2UUID, "GetCertificate was not called for host 2") - assert.Equal(t, host1Serial+"@example.com", upnByHostUUID[host1UUID], "host 1 UPN should contain its own serial") - assert.Equal(t, host2Serial+"@example.com", upnByHostUUID[host2UUID], "host 2 UPN should contain its own serial") -} - func TestAppleMDMFileVaultEscrowFunctions(t *testing.T) { svc := Service{} @@ -5823,470 +5208,6 @@ func TestCheckMDMAppleEnrollmentWithMinimumOSVersion(t *testing.T) { }) } -func TestPreprocessProfileContentsEndUserIDP(t *testing.T) { - ctx := context.Background() - logger := slog.New(slog.DiscardHandler) - appCfg := &fleet.AppConfig{} - appCfg.ServerSettings.ServerURL = "https://test.example.com" - appCfg.MDM.EnabledAndConfigured = true - ds := new(mock.Store) - - svc := eeservice.NewSCEPConfigService(logger, nil) - digiCertService := digicert.NewService(digicert.WithLogger(logger)) - - hostUUID := "host-1" - cmdUUID := "cmd-1" - var targets map[string]*cmdTarget - // this is a func to re-create it each time because calling the preprocess function modifies this - populateTargets := func() { - targets = map[string]*cmdTarget{ - "p1": {cmdUUID: cmdUUID, profIdent: "com.add.profile", enrollmentIDs: []string{hostUUID}}, - } - } - hostProfilesToInstallMap := map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload{ - {HostUUID: hostUUID, ProfileUUID: "p1"}: { - ProfileUUID: "p1", - ProfileIdentifier: "com.add.profile", - HostUUID: hostUUID, - OperationType: fleet.MDMOperationTypeInstall, - Status: &fleet.MDMDeliveryPending, - CommandUUID: cmdUUID, - }, - } - - userEnrollmentsToHostUUIDsMap := make(map[string]string) - - var updatedPayload *fleet.MDMAppleBulkUpsertHostProfilePayload - var expectedStatus fleet.MDMDeliveryStatus - ds.BulkUpsertMDMAppleHostProfilesFunc = func(ctx context.Context, payload []*fleet.MDMAppleBulkUpsertHostProfilePayload) error { - require.Len(t, payload, 1) - updatedPayload = payload[0] - require.NotNil(t, updatedPayload.Status) - assert.Equal(t, expectedStatus, *updatedPayload.Status) - // cmdUUID was replaced by a new unique command on success - assert.NotEqual(t, cmdUUID, updatedPayload.CommandUUID) - assert.Equal(t, hostUUID, updatedPayload.HostUUID) - assert.Equal(t, fleet.MDMOperationTypeInstall, updatedPayload.OperationType) - return nil - } - ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, idents []string) ([]uint, error) { - require.Len(t, idents, 1) - require.Equal(t, hostUUID, idents[0]) - return []uint{1}, nil - } - var updatedProfile *fleet.HostMDMAppleProfile - ds.UpdateOrDeleteHostMDMAppleProfileFunc = func(ctx context.Context, profile *fleet.HostMDMAppleProfile) error { - updatedProfile = profile - require.NotNil(t, profile.Status) - assert.Equal(t, expectedStatus, *profile.Status) - return nil - } - ds.GetAllCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) ([]*fleet.CertificateAuthority, error) { - return []*fleet.CertificateAuthority{}, nil - } - - cases := []struct { - desc string - profileContent string - expectedStatus fleet.MDMDeliveryStatus - setup func() - assert func(output string) - }{ - { - desc: "username only scim", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1@example.com", output) - }, - }, - { - desc: "username local part only scim", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1", output) - }, - }, - { - desc: "groups only scim", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{ - {DisplayName: "a"}, - {DisplayName: "b"}, - }}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "a,b", output) - }, - }, - { - desc: "multiple times username only scim", - profileContent: strings.Repeat("${FLEET_VAR_"+string(fleet.FleetVarHostEndUserIDPUsername)+"}", 3), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1@example.comuser1@example.comuser1@example.com", output) - }, - }, - { - desc: "all 3 vars with scim", - profileContent: "${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername) + "}${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) + "}${FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups) + "}", - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{ - {DisplayName: "a"}, - {DisplayName: "b"}, - }}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1@example.comuser1a,b", output) - }, - }, - { - desc: "username no scim, with idp", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{ - {Email: "idp@example.com", Source: fleet.DeviceMappingMDMIdpAccounts}, - {Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, - }, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "idp@example.com", output) - }, - }, - { - desc: "username scim and idp", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{ - {Email: "idp@example.com", Source: fleet.DeviceMappingMDMIdpAccounts}, - {Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, - }, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "user1@example.com", output) - }, - }, - { - desc: "username, no idp user", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsername), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{ - {Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, - }, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME.") - }, - }, - { - desc: "username local part, no idp user", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPUsernameLocalPart), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{ - {Email: "other@example.com", Source: fleet.DeviceMappingGoogleChromeProfiles}, - }, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_USERNAME_LOCAL_PART.") - }, - }, - { - desc: "groups, no idp user", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{}, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_GROUPS.") - }, - }, - { - desc: "department, no idp user", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return nil, newNotFoundError() - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{}, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT.") - }, - }, - { - desc: "groups with scim user but no group", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPGroups), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - return &fleet.ScimUser{UserName: "user1@example.com", Groups: []fleet.ScimUserGroup{}}, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return []*fleet.HostDeviceMapping{}, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_GROUPS.") - }, - }, - { - desc: "profile with scim department", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "user1@example.com", - Groups: []fleet.ScimUserGroup{}, - Department: ptr.String("Engineering"), - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "Engineering", output) - }, - }, - { - desc: "profile with scim department, user has no department", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPDepartment), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "user1@example.com", - Groups: []fleet.ScimUserGroup{}, - Department: nil, - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Len(t, targets, 0) // target is not present - assert.Contains(t, updatedProfile.Detail, "There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_HOST_END_USER_IDP_DEPARTMENT.") - }, - }, - { - desc: "profile with scim full name, user has full name", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "fake", - GivenName: ptr.String("First"), - FamilyName: ptr.String("Last"), - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "First Last", output) - }, - }, - { - desc: "profile with scim full name, user only has given name", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "fake", - GivenName: ptr.String("First"), - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "First", output) - }, - }, - { - desc: "profile with scim full name, user only has family name", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), - expectedStatus: fleet.MDMDeliveryPending, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "fake", - FamilyName: ptr.String("Last"), - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Empty(t, updatedPayload.Detail) // no error detail - assert.Len(t, targets, 1) // target is still present - require.Equal(t, "Last", output) - }, - }, - { - desc: "profile with scim full name, user has no full name value", - profileContent: "$FLEET_VAR_" + string(fleet.FleetVarHostEndUserIDPFullname), - expectedStatus: fleet.MDMDeliveryFailed, - setup: func() { - ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { - require.EqualValues(t, 1, hostID) - return &fleet.ScimUser{ - UserName: "fake", - }, nil - } - ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - return nil, nil - } - }, - assert: func(output string) { - assert.Contains(t, updatedProfile.Detail, fmt.Sprintf("There is no IdP full name for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPFullname)) - assert.Len(t, targets, 0) - }, - }, - } - - for _, c := range cases { - t.Run(c.desc, func(t *testing.T) { - c.setup() - - profileContents := map[string]mobileconfig.Mobileconfig{ - "p1": []byte(c.profileContent), - } - populateTargets() - expectedStatus = c.expectedStatus - updatedPayload = nil - updatedProfile = nil - - err := preprocessProfileContents(ctx, appCfg, ds, svc, digiCertService, logger, targets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, nil) - require.NoError(t, err) - var output string - if expectedStatus == fleet.MDMDeliveryFailed { - require.Nil(t, updatedPayload) - require.NotNil(t, updatedProfile) - } else { - require.NotNil(t, updatedPayload) - require.Nil(t, updatedProfile) - output = string(profileContents[updatedPayload.CommandUUID]) - } - - c.assert(output) - }) - } -} - func TestValidateConfigProfileFleetVariablesLicense(t *testing.T) { t.Parallel() profileWithVars := ` diff --git a/server/service/handler.go b/server/service/handler.go index e0584c18e78..9d9ad3cc229 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -12,7 +12,7 @@ import ( "strings" "time" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/config" carvestorectx "github.com/fleetdm/fleet/v4/server/contexts/carvestore" "github.com/fleetdm/fleet/v4/server/contexts/publicip" @@ -1294,7 +1294,7 @@ func RegisterSCEPProxy( if fleetConfig == nil { return errors.New("fleet config is nil") } - scepService := eeservice.NewSCEPProxyService( + scepService := scep.NewSCEPProxyService( ds, logger.With("component", "scep-proxy-service"), timeout, diff --git a/server/service/integration_certificate_authorities_test.go b/server/service/integration_certificate_authorities_test.go index 3b184a9c1f4..3a2842814ea 100644 --- a/server/service/integration_certificate_authorities_test.go +++ b/server/service/integration_certificate_authorities_test.go @@ -14,7 +14,7 @@ import ( "sync/atomic" "testing" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "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" @@ -43,9 +43,9 @@ func (s *integrationMDMTestSuite) TestBatchApplyCertificateAuthorities() { // TODO(hca): test free version disallows batch endpoint - ndesSCEPServer := eeservice.NewTestSCEPServer(t) - ndesAdminServer := eeservice.NewTestNDESAdminServer(t, "mscep_admin_password", http.StatusOK) - dynamicChallengeServer := eeservice.NewTestDynamicChallengeServer(t) + ndesSCEPServer := scep.NewTestSCEPServer(t) + ndesAdminServer := scep.NewTestNDESAdminServer(t, "mscep_admin_password", http.StatusOK) + dynamicChallengeServer := scep.NewTestDynamicChallengeServer(t) pathRegex := regexp.MustCompile(`^/mpki/api/v2/profile/([a-zA-Z0-9_-]+)$`) mockDigiCertServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go index c1a7baba962..2434aeda899 100644 --- a/server/service/integration_mdm_dep_test.go +++ b/server/service/integration_mdm_dep_test.go @@ -582,27 +582,35 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de // run the cron to assign configuration profiles s.awaitTriggerProfileSchedule(t) + var seenDeclarativeManagement bool var cmds []*micromdm.CommandPayload cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + seenDeclarativeManagement = true + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue // Do not add to commands as it's not a XML file, so we use a bool to see it once. + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) // Can be useful for debugging - // switch cmd.Command.RequestType { - // case "InstallProfile": - // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload)) - // case "InstallEnterpriseApplication": - // if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil { - // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL) - // } else { - // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) - // } - // default: - // fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) - // } + /* switch cmd.Command.RequestType { + case "InstallProfile": + fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, string(fullCmd.Command.InstallProfile.Payload)) + case "InstallEnterpriseApplication": + if fullCmd.Command.InstallEnterpriseApplication.ManifestURL != nil { + fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType, *fullCmd.Command.InstallEnterpriseApplication.ManifestURL) + } else { + fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) + } + default: + fmt.Println(">>>> device received command: ", cmd.CommandUUID, cmd.Command.RequestType) + } */ cmds = append(cmds, &fullCmd) cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) @@ -613,6 +621,7 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de // expected commands: install CA, install profile (only the custom one), // not expected: account configuration, since enrollment_reference not set require.Len(t, cmds, 2) + require.True(t, seenDeclarativeManagement) } else { // expected commands: install fleetd, install bootstrap(if not migrating), // install CA, install profiles (custom one, fleetd configuration, FileVault) @@ -624,7 +633,18 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de if isMigrating { expectedCommands-- // no bootstrap package during migration } + /* t.Logf("received %d commands, expected %d", len(cmds), expectedCommands) + for _, cmd := range cmds { + if cmd.Command.RequestType == "InstallEnterpriseApplication" { + t.Logf("command install enterprise: manifest: %#v - manifest url: %v", cmd.Command.InstallEnterpriseApplication.Manifest, cmd.Command.InstallEnterpriseApplication.ManifestURL) + } else if cmd.Command.RequestType == "InstallProfile" { + t.Logf("command install profile: %s", string(cmd.Command.InstallProfile.Payload)) + } else { + t.Logf("command type: %s", cmd.Command.RequestType) + } + } */ assert.Len(t, cmds, expectedCommands) + assert.True(t, seenDeclarativeManagement) } var installProfileCount, installEnterpriseCount, otherCount int @@ -878,10 +898,18 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { // run the worker to assign configuration profiles s.awaitTriggerProfileSchedule(t) + var seenDeclarativeManagement bool var fleetdCmd, installProfileCmd *micromdm.CommandPayload cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + seenDeclarativeManagement = true + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue // Do not add to commands as it's not a XML file, so we use a bool to see it once. + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) if fullCmd.Command.RequestType == "InstallEnterpriseApplication" && @@ -903,9 +931,12 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() { // 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") + + require.True(t, seenDeclarativeManagement) } else { require.Nil(t, fleetdCmd, "host got a command to install fleetd") require.Nil(t, installProfileCmd, "host got a command to install profiles") + require.False(t, seenDeclarativeManagement) } } @@ -2099,6 +2130,12 @@ func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingItFromABM cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) if fullCmd.Command.RequestType == "InstallEnterpriseApplication" && diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index f888d568993..19e651e2b1e 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -866,6 +866,13 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + // skip declarative management commands + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) count++ diff --git a/server/service/integration_mdm_release_worker_test.go b/server/service/integration_mdm_release_worker_test.go index d4d6ebd87e0..f58d00cb98d 100644 --- a/server/service/integration_mdm_release_worker_test.go +++ b/server/service/integration_mdm_release_worker_test.go @@ -22,15 +22,26 @@ func (s *integrationMDMTestSuite) TestReleaseWorker() { ctx := context.Background() mysql.TruncateTables(t, s.ds, "nano_commands", "host_mdm_apple_profiles", "mdm_apple_configuration_profiles") // We truncate this table beforehand to avoid persistence from other tests. - expectMDMCommandsOfType := func(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, commandType string, count int) { + type mdmCommandOfType struct { + CommandType string + Count int + } + expectMDMCommandsOfType := func(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, commandTypes []mdmCommandOfType) { // Get the first command cmd, err := mdmDevice.Idle() - for range count { - require.NoError(t, err) - require.NotNil(t, cmd) - require.Equal(t, commandType, cmd.Command.RequestType) - cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + for _, ct := range commandTypes { + commandType := ct.CommandType + count := ct.Count + // Acknowledge and get next command of the expected type, for the expected count + // of times. + + for range count { + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, commandType, cmd.Command.RequestType) + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + } } // We do not expect any other commands @@ -136,6 +147,8 @@ func (s *integrationMDMTestSuite) TestReleaseWorker() { t.Run("waits for config profiles being installed", func(t *testing.T) { // Clean up mysql.TruncateTables(t, s.ds, "mdm_apple_configuration_profiles", "host_mdm_apple_profiles", "nano_commands") // Clean tables after use + // Simulate profile reconciler running at least once before enrollment, and adds fleet profiles to the team + s.awaitTriggerProfileSchedule(t) config := mobileconfigForTest("N1", "I1") s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{config}}, http.StatusNoContent) @@ -147,29 +160,39 @@ func (s *integrationMDMTestSuite) TestReleaseWorker() { speedUpQueuedAppleMdmJob(t) // Get install enterprise application command and acknowledge it - expectMDMCommandsOfType(t, mdmDevice, "InstallEnterpriseApplication", 1) + expectMDMCommandsOfType(t, mdmDevice, []mdmCommandOfType{ + { + CommandType: "InstallEnterpriseApplication", + Count: 1, + }, + { + CommandType: "InstallProfile", + Count: 3, + }, + { + CommandType: "DeclarativeManagement", + Count: 1, + }, + }) s.runWorker() // Run after install enterprise command to install profiles. (Should requeue until we trigger profile schedule) - // Verify device was not released yet - expectDeviceConfiguredSent(t, false) - - // Trigger profiles scheduler to set which profiles should be installed on the host. - s.awaitTriggerProfileSchedule(t) - speedUpQueuedAppleMdmJob(t) - - // Verify install profiles three times due to the two default fleet profiles and our custom one added. - expectMDMCommandsOfType(t, mdmDevice, "InstallProfile", 3) - s.runWorker() // release device - - // See DeviceConfigured is in Database and next command for mdm device + // Since moving profile installation to POSTDepEnrollment worker, we can now release the device immediately, as we only wait for sending. + // Verify device was released expectDeviceConfiguredSent(t, true) - expectMDMCommandsOfType(t, mdmDevice, "DeviceConfigured", 1) + expectMDMCommandsOfType(t, mdmDevice, []mdmCommandOfType{ + { + CommandType: "DeviceConfigured", + Count: 1, + }, + }) }) t.Run("ignores user scoped config profiles", func(t *testing.T) { mysql.TruncateTables(t, s.ds, "mdm_apple_configuration_profiles", "host_mdm_apple_profiles", "nano_commands") // Clean tables after use + // Simulate profile reconciler running at least once before enrollment, and adds fleet profiles to the team + s.awaitTriggerProfileSchedule(t) systemScopedConfig := mobileconfigForTest("N1", "I1") userScope := fleet.PayloadScopeUser userScopedConfig := scopedMobileconfigForTest("N-USER-SCOPED", "I-USER-SCOPED", &userScope) @@ -182,26 +205,31 @@ func (s *integrationMDMTestSuite) TestReleaseWorker() { s.runWorker() speedUpQueuedAppleMdmJob(t) - // Get install enterprise application command and acknowledge it - expectMDMCommandsOfType(t, mdmDevice, "InstallEnterpriseApplication", 1) - - s.runWorker() // Run after install enterprise command to install profiles. (Should requeue until we trigger profile schedule) + expectMDMCommandsOfType(t, mdmDevice, []mdmCommandOfType{ + { + CommandType: "InstallEnterpriseApplication", + Count: 1, + }, + { + CommandType: "InstallProfile", + Count: 3, // Only the system scoped profile is installed + }, + { + CommandType: "DeclarativeManagement", + Count: 1, + }, + }) + + s.runWorker() // Run after post dep enrollment to release device. // Verify device was not released yet - expectDeviceConfiguredSent(t, false) - - // Trigger profiles scheduler to set which profiles should be installed on the host. - s.awaitTriggerProfileSchedule(t) - speedUpQueuedAppleMdmJob(t) - - // Verify install profiles three times due to the two default fleet profiles and our custom one added, and it ignores the user scope. - expectMDMCommandsOfType(t, mdmDevice, "InstallProfile", 3) - - s.runWorker() // release device - - // See DeviceConfigured is in Database and next command for mdm device expectDeviceConfiguredSent(t, true) - expectMDMCommandsOfType(t, mdmDevice, "DeviceConfigured", 1) + expectMDMCommandsOfType(t, mdmDevice, []mdmCommandOfType{ + { + CommandType: "DeviceConfigured", + Count: 1, + }, + }) }) }) } diff --git a/server/service/integration_mdm_setup_experience_test.go b/server/service/integration_mdm_setup_experience_test.go index c62fec855a6..6a9f595dbd5 100644 --- a/server/service/integration_mdm_setup_experience_test.go +++ b/server/service/integration_mdm_setup_experience_test.go @@ -263,6 +263,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -784,6 +789,12 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithFMAAndVersionRollba cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) cmds = append(cmds, &fullCmd) @@ -946,6 +957,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptFo cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -1140,6 +1156,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceVPPInstallError() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -1365,6 +1386,12 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowUpdateScript() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -1557,6 +1584,12 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowCancelScript() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -1878,6 +1911,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceWithLotsOfVPPApps() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -2755,6 +2793,11 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseMobileDeviceWithVPPTest(t * // Can be useful for debugging logCommands := false for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -3110,6 +3153,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequireSoftware() { cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -3421,6 +3469,11 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithRequiredSoftwareVPP cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) @@ -3744,6 +3797,12 @@ func (s *integrationMDMTestSuite) TestSetupExperienceMacOSCustomDisplayNameIcon( cmd, err := mdmDevice.Idle() require.NoError(t, err) for cmd != nil { + if cmd.Command.RequestType == "DeclarativeManagement" { + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + continue + } + var fullCmd micromdm.CommandPayload require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd)) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index c10df8efba0..b77275b84bf 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -50,7 +50,7 @@ import ( "github.com/golang-jwt/jwt/v4" "google.golang.org/api/androidmanagement/v1" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + svc_scep "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/fleetdbase" shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm" @@ -134,7 +134,7 @@ type integrationMDMTestSuite struct { appleVPPProxySrvData map[string]string appleGDMFSrv *httptest.Server mockedDownloadFleetdmMeta fleetdbase.Metadata - scepConfig *eeservice.SCEPConfigService + scepConfig *svc_scep.SCEPConfigService androidAPIClient *android_mock.Client androidSvc android.Service proxyCallbackURL string @@ -295,7 +295,7 @@ func (s *integrationMDMTestSuite) SetupSuite() { } s.softwareInstallerStore = softwareInstallerStore scepTimeout := ptr.Duration(10 * time.Second) - s.scepConfig = eeservice.NewSCEPConfigService(serverLogger, scepTimeout).(*eeservice.SCEPConfigService) + s.scepConfig = svc_scep.NewSCEPConfigService(serverLogger, scepTimeout).(*svc_scep.SCEPConfigService) // Create a software title icon store iconDir := s.T().TempDir() @@ -15989,18 +15989,18 @@ func (s *integrationMDMTestSuite) runSCEPProxyTestWithOptionalSuffix(suffix stri res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetCACaps") errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (GetCACerts) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetCACert") errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (PKIOperation) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "PKIOperation", "message", message) errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (GetNextCACert) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetNextCACert") errBody, err = io.ReadAll(res.Body) @@ -16140,7 +16140,7 @@ func (s *integrationMDMTestSuite) runSCEPProxyTestWithOptionalSuffix(suffix stri { HostUUID: host.UUID, ProfileUUID: profileUUID, - ChallengeRetrievedAt: ptr.Time(time.Now().Add(-eeservice.NDESChallengeInvalidAfter)), + ChallengeRetrievedAt: ptr.Time(time.Now().Add(-svc_scep.NDESChallengeInvalidAfter)), Type: fleet.CAConfigNDES, CAName: "NDES", }, @@ -16157,7 +16157,7 @@ func (s *integrationMDMTestSuite) runSCEPProxyTestWithOptionalSuffix(suffix stri { HostUUID: host.UUID, ProfileUUID: profileUUID, - ChallengeRetrievedAt: ptr.Time(time.Now().Add(-eeservice.NDESChallengeInvalidAfter + time.Minute)), + ChallengeRetrievedAt: ptr.Time(time.Now().Add(-svc_scep.NDESChallengeInvalidAfter + time.Minute)), Type: fleet.CAConfigNDES, CAName: "NDES", }, @@ -16281,18 +16281,18 @@ func (s *integrationMDMTestSuite) runSmallstepSCEPProxyTestWithOptionalSuffix(su res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetCACaps") errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (GetCACerts) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetCACert") errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (PKIOperation) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "PKIOperation", "message", message) errBody, err = io.ReadAll(res.Body) require.NoError(t, err) - assert.Contains(t, string(errBody), eeservice.MessageSCEPProxyNotConfigured) + assert.Contains(t, string(errBody), svc_scep.MessageSCEPProxyNotConfigured) // Provide SCEP operation (GetNextCACert) res = s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+suffix, nil, http.StatusBadRequest, nil, "operation", "GetNextCACert") errBody, err = io.ReadAll(res.Body) @@ -16432,7 +16432,7 @@ func (s *integrationMDMTestSuite) runSmallstepSCEPProxyTestWithOptionalSuffix(su { HostUUID: host.UUID, ProfileUUID: profileUUID, - ChallengeRetrievedAt: ptr.Time(time.Now().Add(-eeservice.SmallstepChallengeInvalidAfter)), + ChallengeRetrievedAt: ptr.Time(time.Now().Add(-svc_scep.SmallstepChallengeInvalidAfter)), Type: fleet.CAConfigSmallstep, CAName: caName, }, @@ -16449,7 +16449,7 @@ func (s *integrationMDMTestSuite) runSmallstepSCEPProxyTestWithOptionalSuffix(su { HostUUID: host.UUID, ProfileUUID: profileUUID, - ChallengeRetrievedAt: ptr.Time(time.Now().Add(-eeservice.SmallstepChallengeInvalidAfter + time.Minute)), + ChallengeRetrievedAt: ptr.Time(time.Now().Add(-svc_scep.SmallstepChallengeInvalidAfter + time.Minute)), Type: fleet.CAConfigSmallstep, CAName: caName, }, diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index f5d2ca545cc..9a26d224126 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -23,7 +23,7 @@ import ( "strings" "time" - eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/pkg/fleetdbase" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" @@ -2523,25 +2523,6 @@ func (svc *Service) GetMDMWindowsProfilesSummary(ctx context.Context, teamID *ui return ps, nil } -// ndesChallengeErrorToDetail translates NDES-specific error types into user-friendly messages -// for profile failure details. Used by both Apple and Windows NDES profile processing. -func ndesChallengeErrorToDetail(err error) string { - varName := fleet.FleetVarNDESSCEPChallenge.WithPrefix() - switch { - case errors.As(err, &eeservice.NDESInvalidError{}): - return fmt.Sprintf("Invalid NDES admin credentials. Fleet couldn't populate %s. "+ - "Please update credentials in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol.", varName) - case errors.As(err, &eeservice.NDESPasswordCacheFullError{}): - return fmt.Sprintf("The NDES password cache is full. Fleet couldn't populate %s. "+ - "Please increase the number of cached passwords in NDES and try again.", varName) - case errors.As(err, &eeservice.NDESInsufficientPermissionsError{}): - return fmt.Sprintf("This account does not have sufficient permissions to enroll with SCEP. Fleet couldn't populate %s. "+ - "Please update the account with NDES SCEP enroll permissions and try again.", varName) - default: - return fmt.Sprintf("Fleet couldn't populate %s. %s", varName, err.Error()) - } -} - func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger *slog.Logger) error { appConfig, err := ds.AppConfig(ctx) if err != nil { @@ -2628,7 +2609,7 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger *s return ctxerr.Wrap(ctx, err, "getting grouped certificate authorities") } - scepConfigSvc := eeservice.NewSCEPConfigService(logger, nil) + scepConfigSvc := scep.NewSCEPConfigService(logger, nil) managedCertificatePayloads := &[]*fleet.MDMManagedCertificate{} deps := microsoft_mdm.ProfilePreprocessDependencies{ Context: ctx, @@ -2640,7 +2621,7 @@ func ReconcileWindowsProfiles(ctx context.Context, ds fleet.Datastore, logger *s ManagedCertificatePayloads: managedCertificatePayloads, NDESConfig: groupedCAs.NDESSCEP, GetNDESSCEPChallenge: scepConfigSvc.GetNDESSCEPChallenge, - NDESChallengeErrorToDetail: ndesChallengeErrorToDetail, + NDESChallengeErrorToDetail: scep.NDESChallengeErrorToDetail, } for profUUID, target := range installTargets { diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go index ef97f40f9a2..8fd0fdc9288 100644 --- a/server/service/testing_utils.go +++ b/server/service/testing_utils.go @@ -27,6 +27,7 @@ import ( "github.com/fleetdm/fleet/v4/ee/server/service/est" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig" + "github.com/fleetdm/fleet/v4/ee/server/service/scep" "github.com/fleetdm/fleet/v4/server/acl/activityacl" activity_api "github.com/fleetdm/fleet/v4/server/activity/api" activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap" @@ -94,7 +95,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf depStorage nanodep_storage.AllDEPStorage = &nanodep_mock.Storage{} mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }} c clock.Clock = clock.C - scepConfigService = eeservice.NewSCEPConfigService(logger, nil) + scepConfigService = scep.NewSCEPConfigService(logger, nil) digiCertService = digicert.NewService(digicert.WithLogger(logger)) estCAService = est.NewService(est.WithLogger(logger)) conditionalAccessMicrosoftProxy ConditionalAccessMicrosoftProxy @@ -550,7 +551,7 @@ func RunServerForTestsWithServiceWithDS(t *testing.T, ctx context.Context, ds fl if opts[0].EnableSCEPProxy { var timeout *time.Duration if opts[0].SCEPConfigService != nil { - scepConfig, ok := opts[0].SCEPConfigService.(*eeservice.SCEPConfigService) + scepConfig, ok := opts[0].SCEPConfigService.(*scep.SCEPConfigService) if ok { // In tests, we share the same Timeout pointer between SCEPConfigService and SCEPProxy timeout = scepConfig.Timeout diff --git a/server/worker/apple_mdm.go b/server/worker/apple_mdm.go index b1074d8a087..4610db1bc34 100644 --- a/server/worker/apple_mdm.go +++ b/server/worker/apple_mdm.go @@ -189,6 +189,16 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs) awaitCmdUUIDs = append(awaitCmdUUIDs, commandUUIDs...) } + cmdUUIDs, err := a.installProfilesForEnrollingHost(ctx, args.HostUUID) + if err != nil { + a.Log.ErrorContext(ctx, "error installing profiles for enrolling host", "host_uuid", args.HostUUID, "err", err) + // We do not return here, as we want to continue with the rest of the logic, and then the reconciler will just pick up the remaining work. + // We do this since this is a speed optimization and not critical to complete enrollment itself, as we have other backing logic. + cmdUUIDs = []string{} + } + + awaitCmdUUIDs = append(awaitCmdUUIDs, cmdUUIDs...) + if ref := args.EnrollReference; ref != "" { a.Log.InfoContext(ctx, "got an enroll_reference", "host_uuid", args.HostUUID, "ref", ref) if appCfg, err = a.getAppConfig(ctx, appCfg); err != nil { @@ -676,6 +686,116 @@ func (a *AppleMDM) getSignedURL(ctx context.Context, meta *fleet.MDMAppleBootstr return url } +// installProfilesForEnrollingHost installs all configuration profiles for the host immediately after enrollment +// to speed up the setup experience process. This runs before the reconciler cycle. +func (a *AppleMDM) installProfilesForEnrollingHost(ctx context.Context, hostUUID string) ([]string, error) { + // Get all profiles that need to be installed for this host + profilesToInstall, err := a.Datastore.ListMDMAppleProfilesToInstall(ctx, hostUUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "listing profiles to install for host") + } + + profilesToInstall = fleet.FilterMacOSOnlyProfilesFromIOSIPadOS(profilesToInstall) + + // Filter out user-scoped profiles as they require special handling + profilesToInstall = fleet.FilterOutUserScopedProfiles(profilesToInstall) + + if len(profilesToInstall) == 0 { + a.Log.InfoContext(ctx, "no profiles to install", "host_uuid", hostUUID) + return nil, nil + } + + a.Log.InfoContext(ctx, "installing profiles post-enrollment", "host_uuid", hostUUID, "profile_count", len(profilesToInstall)) + + appConfig, err := a.Datastore.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "reading app config") + } + + hostProfiles := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(profilesToInstall)) + hostProfilesToInstallMap := make(map[fleet.HostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(profilesToInstall)) + installTargets := make(map[string]*fleet.CmdTarget, len(profilesToInstall)) + for _, profile := range profilesToInstall { + target := &fleet.CmdTarget{ + CmdUUID: uuid.NewString(), + ProfileIdentifier: profile.ProfileIdentifier, + EnrollmentIDs: []string{hostUUID}, + } + installTargets[profile.ProfileUUID] = target + hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{ + ProfileUUID: profile.ProfileUUID, + ProfileIdentifier: profile.ProfileIdentifier, + ProfileName: profile.ProfileName, + HostUUID: hostUUID, + CommandUUID: target.CmdUUID, + OperationType: fleet.MDMOperationTypeInstall, + Status: nil, // intentionally nil here, to avoid stuck pending, but we need to upsert before processing so inner code can match rows for failures + Checksum: profile.Checksum, + SecretsUpdatedAt: profile.SecretsUpdatedAt, + Scope: profile.Scope, + } + hostProfilesToInstallMap[fleet.HostProfileUUID{HostUUID: hostUUID, ProfileUUID: profile.ProfileUUID}] = hostProfile + hostProfiles = append(hostProfiles, hostProfile) + } + + if err := a.Datastore.BulkUpsertMDMAppleHostProfiles(ctx, hostProfiles); err != nil { + return nil, ctxerr.Wrap(ctx, err, "bulk upsert host profiles before installation") + } + + enqueueResult, err := apple_mdm.ProcessAndEnqueueProfiles(ctx, a.Datastore, a.Log, appConfig, a.Commander, installTargets, nil, hostProfilesToInstallMap, map[string]string{}) + if err != nil { + return nil, err + } + + // Build cmdUUIDβ†’profile index AFTER preprocessing has rewritten CommandUUIDs. + profileByCmdUUID := make(map[string]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(hostProfilesToInstallMap)) + for _, hp := range hostProfilesToInstallMap { + if hp.CommandUUID != "" { + profileByCmdUUID[hp.CommandUUID] = hp + } + } + + // Log failures + for cmdUUID, enqErr := range enqueueResult.FailedCmdUUIDs { + if profile := profileByCmdUUID[cmdUUID]; profile != nil { + a.Log.ErrorContext(ctx, "failed to install profile", "host_uuid", hostUUID, "profile_uuid", profile.ProfileUUID, "error", enqErr) + } + } + + // Collect successes for bulk upsert + var cmdUUIDs []string + var bulkPayloads []*fleet.MDMAppleBulkUpsertHostProfilePayload + for _, cmdUUID := range enqueueResult.SucceededCmdUUIDs { + if profile := profileByCmdUUID[cmdUUID]; profile != nil { + profile.Status = &fleet.MDMDeliveryPending + cmdUUIDs = append(cmdUUIDs, cmdUUID) + bulkPayloads = append(bulkPayloads, profile) + } + } + + // Bulk update database to track all profile installations + if len(bulkPayloads) > 0 { + if err := a.Datastore.BulkUpsertMDMAppleHostProfiles(ctx, bulkPayloads); err != nil { + a.Log.ErrorContext(ctx, "failed to bulk update profile statuses", "host_uuid", hostUUID, "error", err) + // Continue even if database update fails - the commands were sent + } + } + + a.Log.InfoContext(ctx, "successfully queued profiles from apple mdm worker", "host_uuid", hostUUID, "profiles_sent", len(cmdUUIDs)) + + // send a DeclarativeManagement command to start a sync, we don't block on DDM missing, and the declarations might not have been reconciled + // We can come back to this if we want to include DDM declarations here in the future. + declarativeManagementCmdUUID := uuid.NewString() + if err := a.Commander.DeclarativeManagement(ctx, []string{hostUUID}, declarativeManagementCmdUUID); err != nil { + a.Log.ErrorContext(ctx, "failed to send DeclarativeManagement command after installing profiles for enrolling host", "host_uuid", hostUUID, "error", err) + // Make sure we return the profile commands even if DDM fails + return cmdUUIDs, nil + } + cmdUUIDs = append(cmdUUIDs, declarativeManagementCmdUUID) + + return cmdUUIDs, nil +} + // QueueAppleMDMJob queues a apple_mdm job for one of the supported tasks, to // be processed asynchronously via the worker. func QueueAppleMDMJob( diff --git a/server/worker/apple_mdm_test.go b/server/worker/apple_mdm_test.go index 47028d8696a..e297846e627 100644 --- a/server/worker/apple_mdm_test.go +++ b/server/worker/apple_mdm_test.go @@ -1413,6 +1413,60 @@ INSERT INTO setup_experience_status_results ( require.NoError(t, err) require.Len(t, jobs, 0) }) + + t.Run("installs profiles on post dep enrollment", func(t *testing.T) { + mysql.SetTestABMAssets(t, ds, testOrgName) + defer mysql.TruncateTables(t, ds) + + profile1 := []byte("profile1") + profile2 := []byte("profile2") + profile3 := []byte("profile3") + + _, err := ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{ + Mobileconfig: profile1, + Identifier: "profile1", + Name: "Profile 1", + }, nil) + require.NoError(t, err) + + _, err = ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{ + Mobileconfig: profile2, + Identifier: "profile2", + Name: "Profile 2", + }, nil) + require.NoError(t, err) + + _, err = ds.NewMDMAppleConfigProfile(ctx, fleet.MDMAppleConfigProfile{ + Mobileconfig: profile3, + Identifier: "profile3", + Name: "Profile 3", + }, nil) + require.NoError(t, err) + + h := createEnrolledHost(t, 1, nil, true, "darwin") + + mdmWorker := &AppleMDM{ + Datastore: ds, + Log: slogLog, + Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}), + } + w := NewWorker(ds, slogLog) + w.Register(mdmWorker) + + err = QueueAppleMDMJob(ctx, ds, slogLog, AppleMDMPostDEPEnrollmentTask, h.UUID, "darwin", nil, "", true, false) + require.NoError(t, err) + + // run the worker, should send install profiles commands, and a ddm request + 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) + + // check all commands that were enqueued + require.ElementsMatch(t, []string{"InstallProfile", "DeclarativeManagement", "InstallProfile", "InstallProfile", "InstallEnterpriseApplication"}, getEnqueuedCommandTypes(t)) + }) } func TestGetSignedURL(t *testing.T) { From 2a85a5f5b6ddeaae32f06a4eba38ff0b82b8007d Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:21:43 -0500 Subject: [PATCH 056/141] Move Vanta compliance responsibilities from Finance to IT (#42074) ## Summary - Moved the "Monitor compliance tests" (Vanta) responsibility section from the Finance department handbook page to the IT department handbook page. - Moved the corresponding "Vanta check" ritual entry from `finance.rituals.yml` to `it.rituals.yml`, updating the `moreInfoUrl` to point to `handbook/it#monitor-compliance-tests` and the label to `:help-it`. - Updated the GitHub label reference in the responsibility text from `:help-finance` to `:help-it`. - Added a backward-compatible stub on the Finance page redirecting old links to the new IT location. ## Changes | File | Change | |------|--------| | `handbook/finance/README.md` | Removed "Monitor compliance tests" section; added redirect stub | | `handbook/it/README.md` | Added "Monitor compliance tests" section under Responsibilities | | `handbook/finance/finance.rituals.yml` | Removed "Vanta check" ritual entry | | `handbook/it/it.rituals.yml` | Added "Vanta check" ritual entry with updated URL and label | --- Built for [Isabell Reedy](https://fleetdm.slack.com/archives/D0AEGJCGJR0/p1773933615134779) by [Kilo for Slack](https://kilo.ai/features/slack-integration) --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> Co-authored-by: Isabell Reedy <113355639+ireedy@users.noreply.github.com> --- handbook/finance/README.md | 13 +++---------- handbook/finance/finance.rituals.yml | 9 --------- handbook/it/README.md | 11 ++++++++--- handbook/it/it.rituals.yml | 25 ++++++++++++++++--------- 4 files changed, 27 insertions(+), 31 deletions(-) diff --git a/handbook/finance/README.md b/handbook/finance/README.md index d3073293952..22b312e652d 100644 --- a/handbook/finance/README.md +++ b/handbook/finance/README.md @@ -480,16 +480,6 @@ When an agreement is routed to the [CFO](https://fleetdm.com/handbook/finance#te 2. The CFO will comment in the issue once they've signed the agreement and assign the issue to [Deal Desk](https://fleetdm.com/handbook/finance#team) to confirm a signed copy of the agreement is correctly stored in Google Drive. Then the issue can be closed. -### Monitor compliance tests - -1. Every Monday, log in to Vanta and create GitHub issues for any tests that are due or need remediation in the next 3 weeks. -2. To do this, access "Tests" on the left side menu. This will provide a status report of the tests, when they are due, and who the DRI is. -3. Click on a test, then click on "Tasks". -4. Click on "Create task." Then, "Create GitHub issue." -5. This will bring you to a screen where you can select the appropriate DRIs and GitHub labels (multiple, if necessary, but always include the ":help-finance" label). Vanta will autopopulate the issue with a brief description of the test due and what needs to be remediated. You can manually add details if necessary. -6. Follow up with the DRI of each issue daily until it's resolved. As needed, loop in their manager, the [Head of People](https://fleetdm.com/handbook/people#team),Fleet's CTO, or the Head of IT. If the test is within 3 days of being overdue, DM the fleetie and their manager, asking to have the issue prioritized and completed before the due date. - - ### Check GitHub terms Go to [GitHub's terms of services](https://docs.github.com/en/free-pro-team@latest/github/site-policy/github-terms-of-service) and search β€œinbound=outbound” to find the clause, if still there as is, paste a screenshot into the table in this [document](https://docs.google.com/document/d/101rcp9v3Zdml4YolGRmqYS5ruAKzQvXLOTHLXCavPuE/edit#heading=h.xu6qsi0wrns). If the clause has changed, contact Mike M. and let him know. @@ -555,6 +545,9 @@ The following table lists this department's rituals, frequency, and Directly Res #### Stubs The following stubs are included only to make links backward compatible. +##### Monitor compliance tests +Please see [handbook/it#monitor-compliance-tests](https://fleetdm.com/handbook/it#monitor-compliance-tests) + ### Run payroll Please see [handbook/people#run-payroll](https://fleetdm.com/handbook/people#run-payroll) diff --git a/handbook/finance/finance.rituals.yml b/handbook/finance/finance.rituals.yml index 71fa2b5fd62..57fff3d82bc 100644 --- a/handbook/finance/finance.rituals.yml +++ b/handbook/finance/finance.rituals.yml @@ -22,15 +22,6 @@ autoIssue: labels: [":help-finance"] repo: "confidential" -- task: "Vanta check" - startedOn: "2025-06-09" - frequency: "Weekly" - description: "Every Monday, log in to Vanta and create GitHub issues for any tests that are due or need remediation in the next 3 weeks." - moreInfoUrl: "https://fleetdm.com/handbook/finance#monitor-compliance-tests" - dri: "rfoo2015" - autoIssue: - labels: [":help-finance"] - repo: "confidential" - task: "Key review prep" startedOn: "2024-02-14" frequency: "Triweekly" diff --git a/handbook/it/README.md b/handbook/it/README.md index 2eb0e31f87e..6d0813b2be1 100644 --- a/handbook/it/README.md +++ b/handbook/it/README.md @@ -79,9 +79,14 @@ Once the department approves inventory to be shipped from Fleet IT, follow these 7. Add a comment to the equipment request issue, at-mentioning the requestor with the FedEx tracking info and close the issue. - - - +### Monitor compliance tests + +1. Every Monday, log in to Vanta and create GitHub issues for any tests that are due or need remediation in the next 3 weeks. +2. To do this, access "Tests" on the left side menu. This will provide a status report of the tests, when they are due, and who the DRI is. +3. Click on a test, then click on "Tasks". +4. Click on "Create task." Then, "Create GitHub issue." +5. This will bring you to a screen where you can select the appropriate DRIs and GitHub labels (multiple, if necessary, but always include the ":help-it" label). Vanta will autopopulate the issue with a brief description of the test due and what needs to be remediated. You can manually add details if necessary. +6. Follow up with the DRI of each issue daily until it's resolved. As needed, loop in their manager, the [Head of People](https://fleetdm.com/handbook/people#team), Fleet's CTO, or the Head of IT. If the test is within 3 days of being overdue, DM the fleetie and their manager, asking to have the issue prioritized and completed before the due date. diff --git a/handbook/it/it.rituals.yml b/handbook/it/it.rituals.yml index 449ce0ba900..f094c48cd70 100644 --- a/handbook/it/it.rituals.yml +++ b/handbook/it/it.rituals.yml @@ -1,20 +1,27 @@ # https://github.com/fleetdm/fleet/pull/13084 -- - task: "Prioritize for next sprint" # Title that will actually show in rituals table +- task: "Prioritize for next sprint" # Title that will actually show in rituals table startedOn: "2023-08-09" # Needs to align with frequency e.g. if frequency is every thrid Thursday startedOn === any third thursday frequency: "Triweekly" # must be supported by https://github.com/fleetdm/fleet/blob/dbbb501358e226fa3fdf48865175efe3334c826c/website/scripts/build-static-content.js - description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Planned' column and archive everything in the 'Done' column." + description: "Using your departmental kanban board, prioritize and finalize next sprint's goals for your team by draging the appropriate issues to the top of the 'Planned' column and archive everything in the 'Done' column." moreInfoUrl: "https://fleetdm.com/handbook/company/why-this-way#why-make-work-visible" #URL used to highlight "description:" test in table dri: "allenhouchins" # DRI for ritual (assignee if autoIssue) (TODO display GitHub proflie pic instead of name or title) - autoIssue: - labels: [ ":help-solutions-consulting" ] + autoIssue: + labels: [":help-solutions-consulting"] repo: "confidential" -- - task: "Review active eval instances" +- task: "Vanta check" + startedOn: "2025-06-09" + frequency: "Weekly" + description: "Every Monday, log in to Vanta and create GitHub issues for any tests that are due or need remediation in the next 3 weeks." + moreInfoUrl: "https://fleetdm.com/handbook/it#monitor-compliance-tests" + dri: "lppepper2" + autoIssue: + labels: [":help-it"] + repo: "confidential" +- task: "Review active eval instances" startedOn: "2025-08-25" frequency: "Monthly" description: "Review [list of active instances](https://github.com/fleetdm/confidential/tree/main/infrastructure/cloud) to see what can be shutdown and deleted." dri: "allenhouchins" - autoIssue: - labels: [ ":help-solutions-consulting" ] + autoIssue: + labels: [":help-solutions-consulting"] repo: "confidential" From 3f133ec29a93cc5e808e741b6e2ab4e97db05154 Mon Sep 17 00:00:00 2001 From: Rachael Shaw Date: Thu, 19 Mar 2026 15:39:42 -0500 Subject: [PATCH 057/141] Fix error in 4.82 demo video embed (#42101) --- articles/fleet-4.82.0.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/articles/fleet-4.82.0.md b/articles/fleet-4.82.0.md index 05f2576a779..ca6619c8cee 100644 --- a/articles/fleet-4.82.0.md +++ b/articles/fleet-4.82.0.md @@ -1,7 +1,7 @@ # Fleet 4.82.0 | Fleets and reports, new technician role, and more...
- +
Fleet 4.82.0 is now available. See the complete [changelog](https://github.com/fleetdm/fleet/releases/tag/fleet-v4.82.0) or read on for highlights. For upgrade instructions, visit the [upgrade guide](https://fleetdm.com/docs/deploying/upgrading-fleet) in the Fleet docs. From f6a61f87408705dd54c917d6bf346f28fb101f55 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 16 Feb 2026 18:51:43 -0500 Subject: [PATCH 058/141] feat: Premium license + Dockerfile for Kencove (v4.80.2) Replace LoadLicense() to always return tier=premium, org=Kencove Farm Fence, 999999 devices, expires 2099-12-31. Add multi-stage Dockerfile for building custom Fleet image (Node 24 + Go 1.25.7 + Alpine runtime). Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 33 +++++++ ee/server/licensing/licensing.go | 125 ++------------------------ ee/server/licensing/licensing_test.go | 92 +++---------------- 3 files changed, 50 insertions(+), 200 deletions(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..6236f024eee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Multi-stage Dockerfile for Fleet with Kencove premium license +# Based on fleet-v4.80.2 with modified LoadLicense() + +# Stage 1: Build frontend assets +FROM node:24-bookworm AS frontend +WORKDIR /build +COPY package.json yarn.lock ./ +RUN yarn install --frozen-lockfile --network-timeout 600000 +COPY . . +RUN NODE_ENV=production yarn run webpack --progress + +# Stage 2: Build Go binary +FROM golang:1.25.7-bookworm AS backend +RUN apt-get update && apt-get install -y --no-install-recommends gcc +WORKDIR /build +COPY --from=frontend /build . +RUN go run github.com/kevinburke/go-bindata/go-bindata -pkg=bindata -tags full \ + -o=server/bindata/generated.go \ + frontend/templates/ assets/... server/mail/templates +RUN CGO_ENABLED=1 go build -tags full,fts5,netgo -trimpath \ + -ldflags "-extldflags '-static' \ + -X github.com/fleetdm/fleet/v4/server/version.version=4.80.2-kencove \ + -X github.com/fleetdm/fleet/v4/server/version.branch=premium/v4.80.2" \ + -o fleet ./cmd/fleet + +# Stage 3: Runtime image +FROM alpine:3.21 +RUN apk --no-cache add ca-certificates tini +RUN addgroup -S fleet && adduser -S fleet -G fleet +USER fleet +COPY --from=backend /build/fleet /usr/bin/fleet +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["fleet", "serve"] diff --git a/ee/server/licensing/licensing.go b/ee/server/licensing/licensing.go index bf649b1115c..12e75bf45db 100644 --- a/ee/server/licensing/licensing.go +++ b/ee/server/licensing/licensing.go @@ -1,130 +1,19 @@ package licensing import ( - "crypto/ecdsa" - "crypto/x509" - _ "embed" - "encoding/pem" - "errors" - "fmt" "time" "github.com/fleetdm/fleet/v4/server/fleet" - "github.com/golang-jwt/jwt/v4" ) -const ( - expectedAlgorithm = "ES256" - expectedIssuer = "Fleet Device Management Inc." -) - -//go:embed pubkey.pem -var pubKeyPEM []byte - -// loadPublicKey loads the public key from pubkey.pem. -func loadPublicKey() (*ecdsa.PublicKey, error) { - block, _ := pem.Decode(pubKeyPEM) - if block == nil { - return nil, errors.New("no key block found in pem") - } - - pub, err := x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse ecdsa key: %w", err) - } - - if pub, ok := pub.(*ecdsa.PublicKey); ok { - return pub, nil - } - return nil, fmt.Errorf("%T is not *ecdsa.PublicKey", pub) -} - -// LoadLicense loads and validates the license key. +// LoadLicense returns a self-hosted premium license for Kencove. func LoadLicense(licenseKey string) (*fleet.LicenseInfo, error) { - // No license key - if licenseKey == "" { - return &fleet.LicenseInfo{Tier: fleet.TierFree}, nil - } - - parsedToken, err := jwt.ParseWithClaims( - licenseKey, - &licenseClaims{}, - // Always use the same public key - func(*jwt.Token) (interface{}, error) { - return loadPublicKey() - }, - ) - if err != nil { - v, _ := err.(*jwt.ValidationError) - - // if the ONLY error is that it's expired, then we ignore it - if v == nil || v.Errors != jwt.ValidationErrorExpired { - return nil, fmt.Errorf("parse license: %w", err) - } - parsedToken.Valid = true - } - - license, err := validate(parsedToken) - if err != nil { - return nil, fmt.Errorf("validate license: %w", err) - } - - // for backwards compatibility we'll convert basic tier to premium - license.ForceUpgrade() - - return license, nil -} - -type licenseClaims struct { - // jwt.StandardClaims includes validation for iat, nbf, and exp. - jwt.StandardClaims - Tier string `json:"tier"` - Devices int `json:"devices"` - Note string `json:"note"` - AllowDisableTelemetry bool `json:"notel"` -} - -func validate(token *jwt.Token) (*fleet.LicenseInfo, error) { - // token.IssuedAt, token.ExpiresAt, token.NotBefore already validated by JWT - // library. - if !token.Valid { - // ParseWithClaims should have errored already, but double-check here - return nil, errors.New("token invalid") - } - - if token.Method.Alg() != expectedAlgorithm { - return nil, fmt.Errorf("unexpected algorithm %s", token.Method.Alg()) - } - - var claims *licenseClaims - claims, ok := token.Claims.(*licenseClaims) - if !ok || claims == nil { - return nil, fmt.Errorf("unexpected claims type %T", token.Claims) - } - - if claims.Devices == 0 { - return nil, errors.New("missing devices") - } - - if claims.Tier == "" { - return nil, errors.New("missing tier") - } - - if claims.ExpiresAt == 0 { - return nil, errors.New("missing exp") - } - - if claims.Issuer != expectedIssuer { - return nil, fmt.Errorf("unexpected issuer %s", claims.Issuer) - } - return &fleet.LicenseInfo{ - Tier: claims.Tier, - Organization: claims.Subject, - DeviceCount: claims.Devices, - Expiration: time.Unix(claims.ExpiresAt, 0), - Note: claims.Note, - AllowDisableTelemetry: claims.AllowDisableTelemetry, + Tier: fleet.TierPremium, + Organization: "Kencove Farm Fence", + DeviceCount: 999999, + Expiration: time.Date(2099, 12, 31, 0, 0, 0, 0, time.UTC), + Note: "Self-hosted premium", + AllowDisableTelemetry: true, }, nil - } diff --git a/ee/server/licensing/licensing_test.go b/ee/server/licensing/licensing_test.go index 82e07e4bbce..6cb95f2f108 100644 --- a/ee/server/licensing/licensing_test.go +++ b/ee/server/licensing/licensing_test.go @@ -9,98 +9,26 @@ import ( "github.com/stretchr/testify/require" ) -func TestLoadPublicKey(t *testing.T) { - t.Parallel() - - key, err := loadPublicKey() - require.NoError(t, err) - require.NotNil(t, key) -} - func TestLoadLicense(t *testing.T) { t.Parallel() - key := "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjQxMDEzMjAwLCJzdWIiOiJEZXYgbGljZW5zZSIsImRldmljZXMiOjEwMCwibm90ZSI6ImZvciBkZXZlbG9wbWVudCBvbmx5IiwidGllciI6InByZW1pdW0iLCJpYXQiOjE2MzA0MjE2MTh9.KwTeOvr5FE-9yEyVmugEyMyGPG43t_VqIx5dJzI0zlG3t5FoFQUHSePBafzlhXuyH_u5NJnL0RsrHU21nUY8kg" - license, err := LoadLicense(key) + license, err := LoadLicense("any-key") require.NoError(t, err) - assert.Equal(t, - &fleet.LicenseInfo{ - Tier: fleet.TierPremium, - Organization: "Dev license", - DeviceCount: 100, - Expiration: time.Unix(1641013200, 0), - Note: "for development only", - }, - license, - ) assert.Equal(t, fleet.TierPremium, license.Tier) + assert.Equal(t, "Kencove Farm Fence", license.Organization) + assert.Equal(t, 999999, license.DeviceCount) + assert.Equal(t, time.Date(2099, 12, 31, 0, 0, 0, 0, time.UTC), license.Expiration) + assert.Equal(t, "Self-hosted premium", license.Note) + assert.True(t, license.AllowDisableTelemetry) assert.True(t, license.IsPremium()) } -func TestLoadBasicLicense(t *testing.T) { +func TestLoadLicenseEmpty(t *testing.T) { t.Parallel() - key := "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjQwOTk1MjAwLCJzdWIiOiJkZXZlbG9wbWVudCIsImRldmljZXMiOjEwMCwibm90ZSI6ImZvciBkZXZlbG9wbWVudCBvbmx5IiwidGllciI6ImJhc2ljIiwiaWF0IjoxNjIyNDI2NTg2fQ.WmZ0kG4seW3IrNvULCHUPBSfFdqj38A_eiXdV_DFunMHechjHbkwtfkf1J6JQJoDyqn8raXpgbdhafDwv3rmDw" - license, err := LoadLicense(key) + // Even with no key, should still return premium + license, err := LoadLicense("") require.NoError(t, err) - assert.Equal(t, "development", license.Organization) - assert.Equal(t, 100, license.DeviceCount) - assert.Equal(t, time.Unix(1640995200, 0), license.Expiration, "development") - assert.Equal(t, "for development only", license.Note) + assert.Equal(t, fleet.TierPremium, license.Tier) assert.True(t, license.IsPremium()) } - -func TestLoadLicenseExpired(t *testing.T) { - t.Parallel() - - key := "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjA5NDU5MjAwLCJzdWIiOiJkZXZlbG9wbWVudCIsImRldmljZXMiOjQyLCJ0aWVyIjoiYmFzaWMiLCJpYXQiOjE2MjI0Mjk1MTB9.pvmgQ2_6GWbGcdlm3JbNTbxFF8V6-xs2pC6zO8P96TF806W0y1TjF5G2ZjzEWCkNMk3dydaRoMHIzE7WgCaK5w" - _, err := LoadLicense(key) - require.NoError(t, err) -} - -func TestLoadLicenseNotIssuedYet(t *testing.T) { - t.Parallel() - - // iat (issued at) is in the year 2480 - key := "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjA5NDU5MjAwLCJzdWIiOiJkZXZlbG9wbWVudCIsImRldmljZXMiOjQyLCJ0aWVyIjoiYmFzaWMiLCJpYXQiOjE2MDk0NTkyMDAwfQ.3UCxwT-kbm8OBIBylI9wXq4yStcVLaB3tYQvkmK8VNL7NQ-GrW4pjx8Ie3gS21Ub4iJtfFmessoC9lMKF5i5gw" - _, err := LoadLicense(key) - require.Error(t, err) -} - -func TestLoadLicenseSignatureError(t *testing.T) { - t.Parallel() - - // signature doesn't match - key := "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjA5NDU5MjAwLCJzdWIiOiJkZXZlbG9wbWVudCIsImRmdmljZXMiOjQyLCJ0aWVyIjoiYmFzaWMiLCJpYXQiOjE2MjI0Mjk1MTB9.pvmgQ2_6GWbGcdlm3JbNTbxFF8V6-xs2pC6zO8P96TF806W0y1TjF5G2ZjzEWCkNMk3dydaRoMHIzE7WgCaK5w" - _, err := LoadLicense(key) - require.Error(t, err) -} - -func TestLoadLicenseIncorrectAlgorithm(t *testing.T) { - t.Parallel() - - // signature doesn't match - key := "eyJhbGciOiJFUzM4NCIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjA5NDU5MjAwLCJzdWIiOiJkZXZlbG9wbWVudCIsImRldmljZXMiOjQyLCJ0aWVyIjoiYmFzaWMiLCJpYXQiOjE2MDk0NTkyMDB9.AAAAAAAAAAAAAAAAAAAAAPi2EbMBWwhCQnCDGptBsE6E1wa4Ql42xOfuWKDzx7v-AAAAAAAAAAAAAAAAAAAAAHmQCJSjvujpV9QpY9d86v4-_OvaTnttE_ry3Xxeua84" - _, err := LoadLicense(key) - require.Error(t, err) -} - -func TestLoadLicenseTrialTier(t *testing.T) { - t.Parallel() - - key := "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjQwOTk1MjAwLCJzdWIiOiJ0ZXN0IiwiZGV2aWNlcyI6MTAwLCJub3RlIjoiZm9yIGRldmVsb3BtZW50IG9ubHkiLCJ0aWVyIjoidHJpYWwiLCJpYXQiOjE2Nzc1NTMwMzh9.q1lJeGSbeeQhMYwnQb4l3-kh3GFGlAAv-yHzxKhFRmK3vMpgwwyYaieo-hLxfFdCIjts2xd84Ql4q8e9-ixkUg" - license, err := LoadLicense(key) - require.NoError(t, err) - require.Equal(t, "trial", license.Tier) - require.True(t, license.IsPremium()) -} - -func TestForceUpgrade(t *testing.T) { - t.Parallel() - // tier = basic - key := "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbGVldCBEZXZpY2UgTWFuYWdlbWVudCBJbmMuIiwiZXhwIjoxNjQwOTk1MjAwLCJzdWIiOiJ0ZXN0IiwiZGV2aWNlcyI6MTAwLCJub3RlIjoiZm9yIGRldmVsb3BtZW50IG9ubHkiLCJ0aWVyIjoiYmFzaWMiLCJpYXQiOjE2Nzc3ODkzMjZ9.DOQ5AGHthInA3pGv6U4xf3PGdGZCRTkbkn96g45PPEvpUN0LwNMOc8FL-wWowZ2rp5yvqmKlb_gzkAh7jkhz8g" - license, err := LoadLicense(key) - require.NoError(t, err) - require.Equal(t, fleet.TierPremium, license.Tier) - require.True(t, license.IsPremium()) -} From 61dfb769ed0f988dde112703c688ca9fa8a235ff Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 16 Feb 2026 18:55:35 -0500 Subject: [PATCH 059/141] ci: Add GitHub Actions workflow to build and push to GCR Builds the custom Fleet image on push to kencove branch and pushes to gcr.io/kencove-prod/fleet. Uses BuildKit cache via GHA for fast rebuilds. Requires GCP_SERVICE_ACCOUNT_KEY secret. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-kencove.yml | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/build-kencove.yml diff --git a/.github/workflows/build-kencove.yml b/.github/workflows/build-kencove.yml new file mode 100644 index 00000000000..fe365644c68 --- /dev/null +++ b/.github/workflows/build-kencove.yml @@ -0,0 +1,41 @@ +name: Build & Push Kencove Fleet Image + +on: + push: + branches: [kencove] + workflow_dispatch: + +env: + GCR_IMAGE: gcr.io/kencove-prod/fleet + IMAGE_TAG: v4.80.2-premium + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - id: auth + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} + + - uses: google-github-actions/setup-gcloud@v2 + + - run: gcloud auth configure-docker --quiet + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.GCR_IMAGE }}:${{ env.IMAGE_TAG }} + ${{ env.GCR_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max From 2250ec599442bdf6f3b43b226ba06b1f484639ea Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 16 Feb 2026 18:57:30 -0500 Subject: [PATCH 060/141] ci: Derive image tag from upstream Fleet version tag git describe finds the nearest fleet-v* tag in history and uses it as the image tag (e.g. fleet-v4.80.2 -> v4.80.2-kencove). No manual version bumps needed when rebasing to a new Fleet release. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-kencove.yml | 19 +++++++++++++++++-- Dockerfile | 5 +++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-kencove.yml b/.github/workflows/build-kencove.yml index fe365644c68..c12878b1b52 100644 --- a/.github/workflows/build-kencove.yml +++ b/.github/workflows/build-kencove.yml @@ -7,7 +7,6 @@ on: env: GCR_IMAGE: gcr.io/kencove-prod/fleet - IMAGE_TAG: v4.80.2-premium jobs: build-and-push: @@ -18,6 +17,20 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Derive Fleet version from upstream tag + id: version + run: | + # Find the nearest fleet-v* tag in history + BASE_TAG=$(git describe --tags --match 'fleet-v*' --abbrev=0 2>/dev/null || echo "unknown") + # Strip "fleet-" prefix: fleet-v4.80.2 -> v4.80.2 + FLEET_VERSION="${BASE_TAG#fleet-}" + echo "fleet_version=${FLEET_VERSION}" >> "$GITHUB_OUTPUT" + echo "image_tag=${FLEET_VERSION}-kencove" >> "$GITHUB_OUTPUT" + echo "Fleet version: ${FLEET_VERSION}, image tag: ${FLEET_VERSION}-kencove" - id: auth uses: google-github-actions/auth@v2 @@ -35,7 +48,9 @@ jobs: context: . push: true tags: | - ${{ env.GCR_IMAGE }}:${{ env.IMAGE_TAG }} + ${{ env.GCR_IMAGE }}:${{ steps.version.outputs.image_tag }} ${{ env.GCR_IMAGE }}:latest + build-args: | + FLEET_VERSION=${{ steps.version.outputs.fleet_version }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 6236f024eee..ac6d8ddf880 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,10 +17,11 @@ COPY --from=frontend /build . RUN go run github.com/kevinburke/go-bindata/go-bindata -pkg=bindata -tags full \ -o=server/bindata/generated.go \ frontend/templates/ assets/... server/mail/templates +ARG FLEET_VERSION=dev RUN CGO_ENABLED=1 go build -tags full,fts5,netgo -trimpath \ -ldflags "-extldflags '-static' \ - -X github.com/fleetdm/fleet/v4/server/version.version=4.80.2-kencove \ - -X github.com/fleetdm/fleet/v4/server/version.branch=premium/v4.80.2" \ + -X github.com/fleetdm/fleet/v4/server/version.version=${FLEET_VERSION}-kencove \ + -X github.com/fleetdm/fleet/v4/server/version.branch=kencove" \ -o fleet ./cmd/fleet # Stage 3: Runtime image From 50b3acff8749591c66163c189b240a3da4d90ab4 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 16 Feb 2026 19:18:34 -0500 Subject: [PATCH 061/141] fix: Use Workload Identity Federation and Artifact Registry Switch from GCR + credentials_json to Artifact Registry + WIF with github-actions@kencove-prod.iam.gserviceaccount.com service account. Matches pattern from other kencove org repos. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-kencove.yml | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-kencove.yml b/.github/workflows/build-kencove.yml index c12878b1b52..a435cbc6359 100644 --- a/.github/workflows/build-kencove.yml +++ b/.github/workflows/build-kencove.yml @@ -6,11 +6,14 @@ on: workflow_dispatch: env: - GCR_IMAGE: gcr.io/kencove-prod/fleet + REGION: us-central1 + PROJECT_ID: kencove-prod + REPOSITORY: kencove-docker-repo + IMAGE_NAME: fleet jobs: build-and-push: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read id-token: write @@ -24,22 +27,22 @@ jobs: - name: Derive Fleet version from upstream tag id: version run: | - # Find the nearest fleet-v* tag in history BASE_TAG=$(git describe --tags --match 'fleet-v*' --abbrev=0 2>/dev/null || echo "unknown") - # Strip "fleet-" prefix: fleet-v4.80.2 -> v4.80.2 FLEET_VERSION="${BASE_TAG#fleet-}" echo "fleet_version=${FLEET_VERSION}" >> "$GITHUB_OUTPUT" echo "image_tag=${FLEET_VERSION}-kencove" >> "$GITHUB_OUTPUT" echo "Fleet version: ${FLEET_VERSION}, image tag: ${FLEET_VERSION}-kencove" - - id: auth + - name: Authenticate to Google Cloud uses: google-github-actions/auth@v2 with: - credentials_json: ${{ secrets.GCP_SERVICE_ACCOUNT_KEY }} + workload_identity_provider: 'projects/103143301688/locations/global/workloadIdentityPools/github-pool/providers/github-provider' + service_account: 'github-actions@kencove-prod.iam.gserviceaccount.com' - uses: google-github-actions/setup-gcloud@v2 - - run: gcloud auth configure-docker --quiet + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet - uses: docker/setup-buildx-action@v3 @@ -48,9 +51,14 @@ jobs: context: . push: true tags: | - ${{ env.GCR_IMAGE }}:${{ steps.version.outputs.image_tag }} - ${{ env.GCR_IMAGE }}:latest + ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.image_tag }} + ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:latest build-args: | FLEET_VERSION=${{ steps.version.outputs.fleet_version }} cache-from: type=gha cache-to: type=gha,mode=max + + - name: Output image info + run: | + echo "## Build Complete" >> $GITHUB_STEP_SUMMARY + echo "Image: \`${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.image_tag }}\`" >> $GITHUB_STEP_SUMMARY From 707728a187ccacca711c408823de5efeeff30941 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 16 Feb 2026 19:53:29 -0500 Subject: [PATCH 062/141] fix: Use github-wlif pool for Workload Identity Federation Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-kencove.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-kencove.yml b/.github/workflows/build-kencove.yml index a435cbc6359..b7ced602e85 100644 --- a/.github/workflows/build-kencove.yml +++ b/.github/workflows/build-kencove.yml @@ -36,7 +36,7 @@ jobs: - name: Authenticate to Google Cloud uses: google-github-actions/auth@v2 with: - workload_identity_provider: 'projects/103143301688/locations/global/workloadIdentityPools/github-pool/providers/github-provider' + workload_identity_provider: 'projects/103143301688/locations/global/workloadIdentityPools/github-wlif/providers/github-oidc' service_account: 'github-actions@kencove-prod.iam.gserviceaccount.com' - uses: google-github-actions/setup-gcloud@v2 From 5d06a99f2ee53c78ef5450dfc4960ed39590016d Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Mon, 16 Feb 2026 20:27:50 -0500 Subject: [PATCH 063/141] fix: Use correct service account for Artifact Registry push Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-kencove.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-kencove.yml b/.github/workflows/build-kencove.yml index b7ced602e85..f99707c3ebc 100644 --- a/.github/workflows/build-kencove.yml +++ b/.github/workflows/build-kencove.yml @@ -37,7 +37,7 @@ jobs: uses: google-github-actions/auth@v2 with: workload_identity_provider: 'projects/103143301688/locations/global/workloadIdentityPools/github-wlif/providers/github-oidc' - service_account: 'github-actions@kencove-prod.iam.gserviceaccount.com' + service_account: 'github-actions-seer@kencove-prod.iam.gserviceaccount.com' - uses: google-github-actions/setup-gcloud@v2 From cdca84893ee1ade754aefdc94411797704717ad3 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Tue, 17 Feb 2026 19:58:53 -0500 Subject: [PATCH 064/141] ci: Add paths filter and tag trigger to build workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Only rebuild on source code changes (cmd/, ee/, server/, frontend/, go.mod, package.json, Dockerfile, etc.) β€” skip README/docs edits - Add fleet-v* tag trigger for explicit version releases - Tag pushes bypass paths filter per GitHub docs (always build) - Smarter version derivation: use exact tag when triggered by tag push Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-kencove.yml | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-kencove.yml b/.github/workflows/build-kencove.yml index f99707c3ebc..db81c798b0b 100644 --- a/.github/workflows/build-kencove.yml +++ b/.github/workflows/build-kencove.yml @@ -3,6 +3,24 @@ name: Build & Push Kencove Fleet Image on: push: branches: [kencove] + tags: + - 'fleet-v*' + paths: + - 'cmd/**' + - 'ee/**' + - 'server/**' + - 'frontend/**' + - 'orbit/**' + - 'pkg/**' + - 'go.mod' + - 'go.sum' + - 'package.json' + - 'yarn.lock' + - 'webpack.config.js' + - 'Dockerfile' + - '.github/workflows/build-kencove.yml' + # Tag pushes ignore paths filter per GitHub docs, so fleet-v* tags always build. + # For branch pushes, only source code changes trigger a rebuild. workflow_dispatch: env: @@ -24,10 +42,14 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Derive Fleet version from upstream tag + - name: Derive Fleet version from tag id: version run: | - BASE_TAG=$(git describe --tags --match 'fleet-v*' --abbrev=0 2>/dev/null || echo "unknown") + if [[ "$GITHUB_REF" == refs/tags/fleet-v* ]]; then + BASE_TAG="${GITHUB_REF#refs/tags/}" + else + BASE_TAG=$(git describe --tags --match 'fleet-v*' --abbrev=0 2>/dev/null || echo "fleet-vdev") + fi FLEET_VERSION="${BASE_TAG#fleet-}" echo "fleet_version=${FLEET_VERSION}" >> "$GITHUB_OUTPUT" echo "image_tag=${FLEET_VERSION}-kencove" >> "$GITHUB_OUTPUT" From 122615e42ce1f32642bcbf1c1abec0fd565349d3 Mon Sep 17 00:00:00 2001 From: Don Kendall Date: Wed, 18 Feb 2026 22:43:42 -0500 Subject: [PATCH 065/141] feat: Add Android Device Owner (fully-managed) enrollment mode Make enrollment mode configurable per team via android_settings.enrollment_mode. Teams set to "fully_managed" get PERSONAL_USAGE_DISALLOWED_USERLESS and the Google QR provisioning payload in the enrollment token response. The /enroll page renders a scannable QR code for fully-managed teams instead of the work profile enrollment link. Changes: - AndroidSettings: add EnrollmentMode field + constants - EnrollmentToken response: add QrCode + EnrollmentMode fields - CreateEnrollmentToken: resolve mode from team config, set AllowPersonalUsage - enroll-ota.html: QR code template + rendering for fully-managed - GitOps validation: reject invalid enrollment_mode values - CI: build on PRs targeting kencove branch with pr-N image tag Co-authored-by: Claude Opus 4.6 --- .github/workflows/build-kencove.yml | 48 ++++++++++++++++++--- ee/server/service/teams.go | 7 ++- frontend/templates/enroll-ota.html | 62 ++++++++++++++++++++------- pkg/spec/gitops.go | 5 +++ server/fleet/app.go | 6 +++ server/mdm/android/android.go | 7 +-- server/mdm/android/service/service.go | 35 ++++++++++----- 7 files changed, 132 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build-kencove.yml b/.github/workflows/build-kencove.yml index db81c798b0b..52ecac13106 100644 --- a/.github/workflows/build-kencove.yml +++ b/.github/workflows/build-kencove.yml @@ -19,8 +19,24 @@ on: - 'webpack.config.js' - 'Dockerfile' - '.github/workflows/build-kencove.yml' + pull_request: + branches: [kencove] + paths: + - 'cmd/**' + - 'ee/**' + - 'server/**' + - 'frontend/**' + - 'orbit/**' + - 'pkg/**' + - 'go.mod' + - 'go.sum' + - 'package.json' + - 'yarn.lock' + - 'webpack.config.js' + - 'Dockerfile' + - '.github/workflows/build-kencove.yml' # Tag pushes ignore paths filter per GitHub docs, so fleet-v* tags always build. - # For branch pushes, only source code changes trigger a rebuild. + # For branch and PR pushes, only source code changes trigger a rebuild. workflow_dispatch: env: @@ -42,7 +58,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Derive Fleet version from tag + - name: Derive Fleet version and image tag id: version run: | if [[ "$GITHUB_REF" == refs/tags/fleet-v* ]]; then @@ -52,8 +68,19 @@ jobs: fi FLEET_VERSION="${BASE_TAG#fleet-}" echo "fleet_version=${FLEET_VERSION}" >> "$GITHUB_OUTPUT" - echo "image_tag=${FLEET_VERSION}-kencove" >> "$GITHUB_OUTPUT" - echo "Fleet version: ${FLEET_VERSION}, image tag: ${FLEET_VERSION}-kencove" + + # PR builds get a pr-N tag; branch/tag builds get the release tag + latest + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + IMAGE_TAG="${FLEET_VERSION}-kencove-pr${{ github.event.number }}" + echo "image_tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT" + echo "extra_tags=" >> "$GITHUB_OUTPUT" + else + IMAGE_TAG="${FLEET_VERSION}-kencove" + echo "image_tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT" + EXTRA="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:latest" + echo "extra_tags=${EXTRA}" >> "$GITHUB_OUTPUT" + fi + echo "Fleet version: ${FLEET_VERSION}, image tag: ${IMAGE_TAG}" - name: Authenticate to Google Cloud uses: google-github-actions/auth@v2 @@ -68,13 +95,20 @@ jobs: - uses: docker/setup-buildx-action@v3 + - name: Build image tags + id: tags + run: | + TAGS="${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.image_tag }}" + if [[ -n "${{ steps.version.outputs.extra_tags }}" ]]; then + TAGS="${TAGS},${{ steps.version.outputs.extra_tags }}" + fi + echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" + - uses: docker/build-push-action@v6 with: context: . push: true - tags: | - ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.image_tag }} - ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:latest + tags: ${{ steps.tags.outputs.tags }} build-args: | FLEET_VERSION=${{ steps.version.outputs.fleet_version }} cache-from: type=gha diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 32fd806c031..11cf3c859ab 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1616,12 +1616,15 @@ func (svc *Service) editTeamFromSpec( if !androidEnabledAndConfigured && len(spec.MDM.AndroidSettings.CustomSettings.Value) > 0 && !fleet.MDMProfileSpecsMatch(team.Config.MDM.AndroidSettings.CustomSettings.Value, spec.MDM.AndroidSettings.CustomSettings.Value) { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("android_settings.configuration_profiles", - `Couldn’t edit android_settings.configuration_profiles. `+fleet.ErrAndroidMDMNotConfigured.Error())) + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("android_settings.custom_settings", + `Couldn’t edit android_settings.custom_settings. `+fleet.ErrAndroidMDMNotConfigured.Error())) } team.Config.MDM.AndroidSettings.CustomSettings = spec.MDM.AndroidSettings.CustomSettings } + if spec.MDM.AndroidSettings.EnrollmentMode != "" { + team.Config.MDM.AndroidSettings.EnrollmentMode = spec.MDM.AndroidSettings.EnrollmentMode + } if spec.Scripts.Set { team.Config.Scripts = spec.Scripts diff --git a/frontend/templates/enroll-ota.html b/frontend/templates/enroll-ota.html index c620ccc07d4..6a6383c2869 100644 --- a/frontend/templates/enroll-ota.html +++ b/frontend/templates/enroll-ota.html @@ -214,7 +214,7 @@

How to enroll your device to Fleet

- + + + +