From 15ec89967ff9783f27405cca427018d47235d408 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 13:18:03 -0400 Subject: [PATCH 1/9] Add regime-aware uniqueness for multiple packages per title (#48396) Replace the version-unique key on software_installers with a regime-aware unique key. A new VIRTUAL dedup_token column resolves to storage_id for custom packages (dedupe by content hash, so Arm and Intel builds of one version coexist while identical bytes are rejected) and to version for FMA packages (version-uniqueness unchanged, so the auto-update cron can cache several versions backed by the same bytes). The migration keeps the first-added row per (global_or_team_id, title_id, dedup_token), re-points policies off the removed rows, and deletes the rest before adding UNIQUE KEY idx_software_installers_dedup. --- ...29163945_MultipleCustomPackagesPerTitle.go | 76 +++++++++ ...945_MultipleCustomPackagesPerTitle_test.go | 156 ++++++++++++++++++ server/datastore/mysql/schema.sql | 7 +- 3 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go create mode 100644 server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go new file mode 100644 index 00000000000..a775973f723 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -0,0 +1,76 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260629163945, Down_20260629163945) +} + +func Up_20260629163945(tx *sql.Tx) error { + // A title may now have more than one package. dedup_token makes uniqueness + // regime-aware: custom rows resolve it to storage_id so they dedupe by content hash + // (Arm and Intel of the same version coexist, identical bytes are rejected), while + // FMA rows resolve it to version so version-uniqueness is unchanged and the same + // bytes can back several versions. A (team, title) is single-regime, and a hash + // never equals a version string, so the two token spaces don't collide. The column + // is VIRTUAL so the add is in-place; its only consumer is the unique key below. + if _, err := tx.Exec(` + ALTER TABLE software_installers + ADD COLUMN dedup_token VARCHAR(255) + GENERATED ALWAYS AS (IF(fleet_maintained_app_id IS NULL, storage_id, version)) VIRTUAL + `); err != nil { + return fmt.Errorf("adding dedup_token column: %w", err) + } + + // Where a (global_or_team_id, title_id, dedup_token) has more than one row, keep the + // first-added (smallest id) as the survivor and delete the rest, so the unique key + // below can be added. This collapses the duplicate-active custom rows from the P1 bug + // and any custom same-hash duplicates. FMA rows already satisfy version-uniqueness. + // Re-point policies off the deleted rows first, since policies.software_installer_id + // is RESTRICT. + const dupGroups = ` + SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id + FROM software_installers + WHERE title_id IS NOT NULL + GROUP BY global_or_team_id, title_id, dedup_token + HAVING COUNT(*) > 1` + + if _, err := tx.Exec(fmt.Sprintf(` + UPDATE policies p + JOIN software_installers si ON si.id = p.software_installer_id + JOIN (%s) dup + ON si.global_or_team_id = dup.global_or_team_id + AND si.title_id = dup.title_id + AND si.dedup_token = dup.dedup_token + SET p.software_installer_id = dup.keep_id + WHERE si.id <> dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("re-pointing policies off duplicate installers: %w", err) + } + + if _, err := tx.Exec(fmt.Sprintf(` + DELETE si FROM software_installers si + JOIN (%s) dup + ON si.global_or_team_id = dup.global_or_team_id + AND si.title_id = dup.title_id + AND si.dedup_token = dup.dedup_token + WHERE si.id <> dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("deleting duplicate installers: %w", err) + } + + if _, err := tx.Exec(` + ALTER TABLE software_installers + DROP INDEX idx_software_installers_team_title_version, + ADD UNIQUE KEY idx_software_installers_dedup (global_or_team_id, title_id, dedup_token) + `); err != nil { + return fmt.Errorf("swapping software_installers unique key: %w", err) + } + + return nil +} + +func Down_20260629163945(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go new file mode 100644 index 00000000000..5b42c76c04d --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -0,0 +1,156 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20260629163945(t *testing.T) { + db := applyUpToPrev(t) + + insertTitle := func(name string, source string) int64 { + return execNoErrLastID(t, db, `INSERT INTO software_titles (name, source) VALUES (?, ?)`, name, source) + } + + const installerInsert = ` + INSERT INTO software_installers + (team_id, global_or_team_id, title_id, filename, extension, version, platform, + install_script_content_id, uninstall_script_content_id, storage_id, package_ids, patch_query, + fleet_maintained_app_id, is_active) + VALUES (?, ?, ?, ?, 'pkg', ?, ?, ?, ?, ?, '', '', ?, ?)` + + insertScript := func(seed string) int64 { + return execNoErrLastID(t, db, `INSERT INTO script_contents (contents, md5_checksum) VALUES ('#!/bin/sh', UNHEX(MD5(?)))`, seed) + } + + // teamID nil means no-team (global_or_team_id 0); fmaID nil means a custom package. + args := func(titleID int64, teamID *int64, platform string, version string, storage string, fmaID *int64, active int) []any { + script := insertScript(storage + version) + var globalOrTeamID int64 + if teamID != nil { + globalOrTeamID = *teamID + } + return []any{teamID, globalOrTeamID, titleID, storage + "-" + version + ".pkg", version, platform, script, script, storage, fmaID, active} + } + insertInstaller := func(titleID int64, teamID *int64, platform string, version string, storage string, fmaID *int64, active int) int64 { + return execNoErrLastID(t, db, installerInsert, args(titleID, teamID, platform, version, storage, fmaID, active)...) + } + tryInsertInstaller := func(titleID int64, teamID *int64, platform string, version string, storage string, fmaID *int64, active int) error { + _, err := db.Exec(installerInsert, args(titleID, teamID, platform, version, storage, fmaID, active)...) + return err + } + + countRows := func(query string, qargs ...any) int { + var n int + require.NoError(t, db.QueryRow(query, qargs...).Scan(&n)) + return n + } + remainingIDs := func(titleID int64) []int64 { + var ids []int64 + r, err := db.Query(`SELECT id FROM software_installers WHERE title_id = ? ORDER BY id`, titleID) + require.NoError(t, err) + for r.Next() { + var id int64 + require.NoError(t, r.Scan(&id)) + ids = append(ids, id) + } + require.NoError(t, r.Err()) + require.NoError(t, r.Close()) + return ids + } + + team := execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES ('Team 1')`) + label := execNoErrLastID(t, db, `INSERT INTO labels (name, query) VALUES ('L1', '')`) + category := execNoErrLastID(t, db, `INSERT INTO software_categories (name) VALUES ('C1')`) + fma := execNoErrLastID(t, db, `INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier) VALUES ('AppD', 'appd/darwin', 'darwin', 'com.appd')`) + + // Single custom package (no-team). Untouched. + titleA := insertTitle("AppA", "apps") + soloID := insertInstaller(titleA, nil, "darwin", "1.0", "hash-a", nil, 1) + + // Custom hash-duplicate (no-team): two active rows with the same content but + // different versions, so the still-present version key allows seeding both. Each + // carries a label and a category, and a policy points at the row to be deleted. + titleB := insertTitle("AppB", "programs") + keepB := insertInstaller(titleB, nil, "windows", "1.0", "hash-b", nil, 1) + dupB := insertInstaller(titleB, nil, "windows", "2.0", "hash-b", nil, 1) + for _, id := range []int64{keepB, dupB} { + execNoErr(t, db, `INSERT INTO software_installer_labels (software_installer_id, label_id) VALUES (?, ?)`, id, label) + execNoErr(t, db, `INSERT INTO software_installer_software_categories (software_installer_id, software_category_id) VALUES (?, ?)`, id, category) + } + policyID := execNoErrLastID(t, db, ` + INSERT INTO policies (name, query, description, checksum, software_installer_id) + VALUES ('p1', 'SELECT 1', '', UNHEX(MD5('p1')), ?)`, dupB) + + // Custom hash-duplicate scoped to a team, different source. + titleC := insertTitle("AppC", "rpm_packages") + keepC := insertInstaller(titleC, &team, "linux", "1.0", "hash-c", nil, 1) + dupC := insertInstaller(titleC, &team, "linux", "1.1", "hash-c", nil, 1) + + // FMA with the same bytes backing two versions. Tokens resolve to version, so both + // must survive. + titleD := insertTitle("AppD", "apps") + fmaOld := insertInstaller(titleD, nil, "darwin", "1.0", "hash-d", &fma, 0) + fmaActive := insertInstaller(titleD, nil, "darwin", "2.0", "hash-d", &fma, 1) + + applyNext(t, db) + + // The version key is gone, replaced by the regime-aware dedup key. + require.Zero(t, countRows(` + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'software_installers' + AND index_name = 'idx_software_installers_team_title_version'`)) + var dedupCols []string + rows, err := db.Query(` + SELECT column_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'software_installers' + AND index_name = 'idx_software_installers_dedup' + ORDER BY seq_in_index`) + require.NoError(t, err) + for rows.Next() { + var col string + require.NoError(t, rows.Scan(&col)) + dedupCols = append(dedupCols, col) + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + require.Equal(t, []string{"global_or_team_id", "title_id", "dedup_token"}, dedupCols) + + // Single-package title untouched. + require.Equal(t, []int64{soloID}, remainingIDs(titleA)) + + // Custom hash-duplicates collapse to the first-added row, which stays active. + require.Equal(t, []int64{keepB}, remainingIDs(titleB)) + require.Equal(t, []int64{keepC}, remainingIDs(titleC)) + require.NotContains(t, remainingIDs(titleC), dupC) + require.Equal(t, 1, countRows(`SELECT is_active FROM software_installers WHERE id = ?`, keepB)) + require.Equal(t, 1, countRows(`SELECT is_active FROM software_installers WHERE id = ?`, keepC)) + + // The survivor keeps its label and category; the deleted row's cascade away. + require.Equal(t, 1, countRows(`SELECT COUNT(*) FROM software_installer_labels WHERE software_installer_id = ?`, keepB)) + require.Equal(t, 1, countRows(`SELECT COUNT(*) FROM software_installer_software_categories WHERE software_installer_id = ?`, keepB)) + require.Zero(t, countRows(`SELECT COUNT(*) FROM software_installer_labels WHERE software_installer_id = ?`, dupB)) + require.Zero(t, countRows(`SELECT COUNT(*) FROM software_installer_software_categories WHERE software_installer_id = ?`, dupB)) + + // The policy was re-pointed off the deleted row onto the survivor. + var repointed int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM policies WHERE id = ?`, policyID).Scan(&repointed)) + require.Equal(t, keepB, repointed) + + // FMA same-hash-different-version rows both survive. + require.Equal(t, []int64{fmaOld, fmaActive}, remainingIDs(titleD)) + + // New key behavior. Custom same-version-different-hash (Arm vs Intel) is accepted; + // these could not be seeded before the migration because the old version key blocked + // two rows sharing a version. + titleE := insertTitle("AppE", "apps") + require.NoError(t, tryInsertInstaller(titleE, nil, "darwin", "9.0", "hash-e1", nil, 1)) + require.NoError(t, tryInsertInstaller(titleE, nil, "darwin", "9.0", "hash-e2", nil, 1)) + + // A second package with identical bytes on the same title is rejected by the key. + require.Error(t, tryInsertInstaller(titleE, nil, "darwin", "8.0", "hash-e1", nil, 1)) + + // FMA can still cache another version backed by the same bytes. + require.NoError(t, tryInsertInstaller(titleD, nil, "darwin", "3.0", "hash-d", &fma, 0)) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 925c9cd199b..8f11728fdb3 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -2089,9 +2089,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=559 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=560 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,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,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260611202649,1,'2020-01-01 01:01:01'),(549,20260615135619,1,'2020-01-01 01:01:01'),(550,20260617172853,1,'2020-01-01 01:01:01'),(551,20260617194413,1,'2020-01-01 01:01:01'),(552,20260622124714,1,'2020-01-01 01:01:01'),(553,20260622124734,1,'2020-01-01 01:01:01'),(554,20260623140135,1,'2020-01-01 01:01:01'),(555,20260624152755,1,'2020-01-01 01:01:01'),(556,20260624210253,1,'2020-01-01 01:01:01'),(557,20260624210311,1,'2020-01-01 01:01:01'),(558,20260626120000,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,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260611202649,1,'2020-01-01 01:01:01'),(549,20260615135619,1,'2020-01-01 01:01:01'),(550,20260617172853,1,'2020-01-01 01:01:01'),(551,20260617194413,1,'2020-01-01 01:01:01'),(552,20260622124714,1,'2020-01-01 01:01:01'),(553,20260622124734,1,'2020-01-01 01:01:01'),(554,20260623140135,1,'2020-01-01 01:01:01'),(555,20260624152755,1,'2020-01-01 01:01:01'),(556,20260624210253,1,'2020-01-01 01:01:01'),(557,20260624210311,1,'2020-01-01 01:01:01'),(558,20260626120000,1,'2020-01-01 01:01:01'),(559,20260629163945,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` ( @@ -3034,8 +3034,9 @@ CREATE TABLE `software_installers` ( `is_active` tinyint(1) NOT NULL DEFAULT '0', `patch_query` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `http_etag` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `dedup_token` varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (if((`fleet_maintained_app_id` is null),`storage_id`,`version`)) VIRTUAL, PRIMARY KEY (`id`), - UNIQUE KEY `idx_software_installers_team_title_version` (`global_or_team_id`,`title_id`,`version`), + UNIQUE KEY `idx_software_installers_dedup` (`global_or_team_id`,`title_id`,`dedup_token`), KEY `fk_software_installers_title` (`title_id`), KEY `fk_software_installers_install_script_content_id` (`install_script_content_id`), KEY `fk_software_installers_post_install_script_content_id` (`post_install_script_content_id`), From 6a2ccb979fde02b528604817fe184cdf4626d6bc Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 16:21:11 -0400 Subject: [PATCH 2/9] Hash-based duplicate guard and 10-package limit for custom software (#48396) Allow multiple custom packages per title in MatchOrCreateSoftwareInstaller. checkSoftwareConflictsByIdentifier keeps the cross-type VPP/in-house checks and the FMA-vs-custom single-regime guard, then rejects a custom package whose dedup_token (content hash) already backs the title and enforces a 10-package limit. The conflict copy now distinguishes VPP apps, Fleet-maintained apps, and software packages. The cross-type and FMA checks run before getOrGenerateSoftwareInstallerTitleID so a rejected upload never triggers its title-create/rename side effects. --- server/datastore/mysql/in_house_apps.go | 22 -- server/datastore/mysql/software_installers.go | 204 +++++++++++------- .../mysql/software_installers_test.go | 148 ++++++++++--- server/fleet/errors.go | 5 + server/service/integration_enterprise_test.go | 16 +- .../service/integration_vpp_install_test.go | 4 +- 6 files changed, 267 insertions(+), 132 deletions(-) diff --git a/server/datastore/mysql/in_house_apps.go b/server/datastore/mysql/in_house_apps.go index fdc77ba8098..7fb5c1e5439 100644 --- a/server/datastore/mysql/in_house_apps.go +++ b/server/datastore/mysql/in_house_apps.go @@ -1653,28 +1653,6 @@ WHERE return exists == 1, nil } -func (ds *Datastore) checkInstallerExistsByName(ctx context.Context, q sqlx.QueryerContext, teamID *uint, name, source, platform string) (bool, error) { - const stmt = ` -SELECT 1 -FROM - software_titles st - INNER JOIN software_installers ON st.id = software_installers.title_id - AND software_installers.global_or_team_id = ? -WHERE - st.name = ? - AND st.source = ? - AND st.extension_for = '' - AND software_installers.platform = ? -` - - var exists int - err := sqlx.GetContext(ctx, q, &exists, stmt, ptr.ValOrZero(teamID), name, source, platform) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return false, ctxerr.Wrap(ctx, err, "check installer exists by name") - } - return exists == 1, nil -} - func (ds *Datastore) checkInHouseAppExistsForAdamID(ctx context.Context, q sqlx.QueryerContext, teamID *uint, appID fleet.VPPAppID) (exists bool, title string, err error) { const stmt = ` SELECT st.name diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index b51e206d944..878ab3cbd31 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -198,16 +198,8 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, errors.New("validated labels must not be nil") } - err = ds.checkSoftwareConflictsByIdentifier(ctx, payload) - if err != nil { - teamName, err := ds.getTeamName(ctx, payload.TeamID) - if err != nil { - return 0, 0, ctxerr.Wrap(ctx, err, "get team for installer conflict error") - } - - return 0, 0, ctxerr.Wrap(ctx, fleet.ConflictError{ - Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage, payload.Title, teamName), - }, "vpp app conflicts with existing software installer") + if err := ds.checkSoftwareConflictsByIdentifier(ctx, payload); err != nil { + return 0, 0, err } // Insert in house app instead of software installer @@ -237,37 +229,6 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID") } - // Enforce team-scoped uniqueness by storage hash, aligning upload behavior with GitOps. - // However, if the duplicate-by-hash is for the same title/source on the same team, - // let the DB unique (team,title) constraint surface the conflict (so tests expecting - // a 409 Conflict with "already exists" still pass). - // Only validate for script packages (.sh/.ps1) where content hash equals functionality. - // Binary installers can legitimately share content with different install scripts. - if payload.StorageID != "" && fleet.IsScriptPackage(payload.Extension) { - var tmID uint - if payload.TeamID != nil { - tmID = *payload.TeamID - } - // Check duplicates by content hash only (ignore URL) to align with GitOps/apply rules. - teamsByHash, err := ds.GetTeamsWithInstallerByHash(ctx, payload.StorageID, "") - if err != nil { - return 0, 0, ctxerr.Wrap(ctx, err, "check duplicate installer by hash") - } - if found, exists := teamsByHash[tmID]; exists { - // If the existing installer has the same title and source, allow the insert to proceed - // so that the existing UNIQUE (global_or_team_id, title_id) constraint yields a - // Conflict error with the expected message. - // Since this is not an in-house app, only one installer per team can exist. - if !(found[0].Title == payload.Title && found[0].Source == payload.Source) { - return 0, 0, fleet.NewInvalidArgumentError( - "software", - "Couldn't add software. An installer with identical contents already exists on this fleet.", - ) - } - // If exact duplicate (same title and source), continue to let DB constraint handle it - } - } - if err := ds.addSoftwareTitleToMatchingSoftware(ctx, titleID, payload); err != nil { return 0, 0, ctxerr.Wrap(ctx, err, "add software title to matching software") } @@ -511,27 +472,47 @@ func getAvailablePolicyName(ctx context.Context, db sqlx.QueryerContext, teamID return availableName, nil } +func softwareInstallerTitleSelect(payload *fleet.UploadSoftwareInstallerPayload) (string, []any) { + switch { + case payload.BundleIdentifier != "": + // match by bundle identifier and source first, or standard matching if we don't have a bundle identifier match + return `SELECT id FROM software_titles WHERE (bundle_identifier = ? AND source = ?) OR (name = ? AND source = ? AND extension_for = '') ORDER BY bundle_identifier = ? DESC LIMIT 1`, + []any{payload.BundleIdentifier, payload.Source, payload.Title, payload.Source, payload.BundleIdentifier} + case payload.Source == "programs" && payload.UpgradeCode != "": + // select by either name or upgrade code, preferring upgrade code + return `SELECT id FROM software_titles WHERE (name = ? AND source = ? AND extension_for = '' AND upgrade_code = '') OR upgrade_code = ? ORDER BY upgrade_code = ? DESC LIMIT 1`, + []any{payload.Title, payload.Source, payload.UpgradeCode, payload.UpgradeCode} + default: + return `SELECT id FROM software_titles WHERE name = ? AND source = ? AND extension_for = ''`, + []any{payload.Title, payload.Source} + } +} + +func (ds *Datastore) getExistingSoftwareInstallerTitleID(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { + stmt, args := softwareInstallerTitleSelect(payload) + var titleID uint + switch err := sqlx.GetContext(ctx, ds.reader(ctx), &titleID, stmt, args...); { + case err == nil: + return titleID, nil + case errors.Is(err, sql.ErrNoRows): + return 0, notFound("SoftwareTitle") + default: + return 0, ctxerr.Wrap(ctx, err, "get existing software installer title id") + } +} + func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { - selectStmt := `SELECT id FROM software_titles WHERE name = ? AND source = ? AND extension_for = ''` - selectArgs := []any{payload.Title, payload.Source} + selectStmt, selectArgs := softwareInstallerTitleSelect(payload) insertStmt := `INSERT INTO software_titles (name, source, extension_for) VALUES (?, ?, '')` insertArgs := []any{payload.Title, payload.Source} // upgrade_code should be set to NULL for non-Windows software, empty or non-empty string for Windows software if payload.Source == "programs" { - // select by either name or upgrade code, preferring upgrade code - if payload.UpgradeCode != "" { - selectStmt = `SELECT id FROM software_titles WHERE (name = ? AND source = ? AND extension_for = '' AND upgrade_code = '') OR upgrade_code = ? ORDER BY upgrade_code = ? DESC LIMIT 1` - selectArgs = []any{payload.Title, payload.Source, payload.UpgradeCode, payload.UpgradeCode} - } insertStmt = `INSERT INTO software_titles (name, source, extension_for, upgrade_code) VALUES (?, ?, '', ?)` insertArgs = []any{payload.Title, payload.Source, payload.UpgradeCode} } if payload.BundleIdentifier != "" { - // match by bundle identifier and source first, or standard matching if we don't have a bundle identifier match - selectStmt = `SELECT id FROM software_titles WHERE (bundle_identifier = ? AND source = ?) OR (name = ? AND source = ? AND extension_for = '') ORDER BY bundle_identifier = ? DESC LIMIT 1` - selectArgs = []any{payload.BundleIdentifier, payload.Source, payload.Title, payload.Source, payload.BundleIdentifier} insertStmt = `INSERT INTO software_titles (name, source, bundle_identifier, extension_for) VALUES (?, ?, ?, '')` insertArgs = []any{payload.Title, payload.Source, payload.BundleIdentifier} } @@ -4108,7 +4089,19 @@ LIMIT 1` return &installer, nil } +const maxPackagesPerTitle = 10 + func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error { + conflict := func(message string) error { + teamName, err := ds.getTeamName(ctx, payload.TeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get team for installer conflict error") + } + return ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(message, payload.Title, teamName), + }, "software conflicts with existing software on the title") + } + switch payload.Platform { // currently, the platform will always be ios for .ipa files case string(fleet.IOSPlatform), string(fleet.IPadOSPlatform): @@ -4123,7 +4116,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return ctxerr.Wrap(ctx, err, "check if VPP app exists for title identifier") } if exists { - return alreadyExists("VPP app", payload.Title) + return conflict(fleet.SoftwareAlreadyHasVPPAppMessage) } // check if equivalent installers exist, duplicate in-house apps are checked in insertInHouseApp @@ -4132,7 +4125,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return ctxerr.Wrap(ctx, err, "check if software installer exists for title identifier") } if exists { - return alreadyExists("software installer", payload.Title) + return conflict(fleet.SoftwareAlreadyHasPackageMessage) } } case string(fleet.MacOSPlatform): @@ -4141,41 +4134,104 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return ctxerr.Wrap(ctx, err, "check if VPP app exists for title identifier") } if exists { - return alreadyExists("VPP app", payload.Title) + return conflict(fleet.SoftwareAlreadyHasVPPAppMessage) } + } - // check only for installers, since in-house apps target iOS/iPadOS so they won't conflict - exists, err = ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.BundleIdentifier, payload.Platform, softwareTypeInstaller) - if err != nil { - return ctxerr.Wrap(ctx, err, "check if installer exists for title identifier") + // custom packages and Fleet-maintained apps can't share a title + mixed, err := ds.checkFleetMaintainedAppExists(ctx, payload) + if err != nil { + return err + } + if mixed { + if payload.FleetMaintainedAppID != nil { + return conflict(fleet.SoftwareAlreadyHasPackageMessage) } - if exists { - return alreadyExists("installer", payload.Title) + return conflict(fleet.SoftwareAlreadyHasFleetMaintainedAppMessage) + } + + if payload.FleetMaintainedAppID == nil { + titleID, err := ds.getExistingSoftwareInstallerTitleID(ctx, payload) + if fleet.IsNotFound(err) { + return nil } - case "windows", "linux": - // check by name before any software title renaming side effects can happen - exists, err := ds.checkInstallerExistsByName(ctx, ds.reader(ctx), payload.TeamID, payload.Title, payload.Source, payload.Platform) if err != nil { - return ctxerr.Wrap(ctx, err, "check if installer exists by name") + return err } - if exists { - return alreadyExists("installer", payload.Title) + + // check if a custom package with the same hash exists + var dup bool + err = sqlx.GetContext(ctx, ds.reader(ctx), &dup, ` + SELECT EXISTS ( + SELECT 1 FROM software_installers + WHERE global_or_team_id = ? AND title_id = ? AND dedup_token = ? + )`, ptr.ValOrZero(payload.TeamID), titleID, payload.StorageID) + if err != nil { + return ctxerr.Wrap(ctx, err, "check duplicate package by hash") + } + if dup { + return ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageHashConflictMessage, payload.Filename), + }, "duplicate package by hash") } - if payload.UpgradeCode != "" { - exists, err := ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.UpgradeCode, payload.Platform, softwareTypeInstaller) - if err != nil { - return ctxerr.Wrap(ctx, err, "check if installer exists for upgrade code") - } - if exists { - return alreadyExists("installer", payload.Title) - } + // a title holds at most maxPackagesPerTitle custom packages + var count int + err = sqlx.GetContext(ctx, ds.reader(ctx), &count, ` + SELECT COUNT(*) FROM software_installers + WHERE global_or_team_id = ? AND title_id = ?`, ptr.ValOrZero(payload.TeamID), titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "count packages on the title") + } + if count >= maxPackagesPerTitle { + return ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageLimitMessage, payload.Title, maxPackagesPerTitle), + }, "package limit reached") } } return nil } +func (ds *Datastore) checkFleetMaintainedAppExists(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (bool, error) { + // look for the other kind of package on the title: an FMA when adding a custom + // package, a custom package when adding an FMA. Matched by bundle identifier on + // macOS and by name or upgrade code on Windows; FMAs only exist on those platforms. + wantFMA := payload.FleetMaintainedAppID == nil + var stmt string + var args []any + switch payload.Platform { + case string(fleet.MacOSPlatform): + stmt = ` + SELECT EXISTS ( + SELECT 1 + FROM software_installers si + JOIN software_titles st ON st.id = si.title_id + WHERE si.global_or_team_id = ? AND st.source = ? AND st.bundle_identifier = ? + AND (si.fleet_maintained_app_id IS NOT NULL) = ? + )` + args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.BundleIdentifier, wantFMA} + case "windows": + stmt = ` + SELECT EXISTS ( + SELECT 1 + FROM software_installers si + JOIN software_titles st ON st.id = si.title_id + WHERE si.global_or_team_id = ? AND st.source = ? AND (st.name = ? OR (? != '' AND st.upgrade_code = ?)) + AND (si.fleet_maintained_app_id IS NOT NULL) = ? + )` + args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.Title, payload.UpgradeCode, payload.UpgradeCode, wantFMA} + default: + return false, nil + } + + var exists bool + if err := sqlx.GetContext(ctx, ds.reader(ctx), &exists, stmt, args...); err != nil { + return false, ctxerr.Wrap(ctx, err, "check fleet-maintained app exists") + } + return exists, nil +} + func (ds *Datastore) GetSoftwareTitlesForInstallAll(ctx context.Context, host *fleet.Host, categoryID *uint) ([]*fleet.HostSoftwareWithInstaller, *string, error) { // get software category and check that it exists var categoryName *string diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 7092f3508e0..3677f72cbe9 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "database/sql" - "errors" "fmt" "os" "path/filepath" @@ -4428,13 +4427,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "a.sh", "title-a")) require.NoError(t, err) - // Duplicate on Team A with different name/title but same hash → reject + // Same hash under a different title on the same team → allowed (dedupe is title-scoped) _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "b.sh", "title-b")) - require.Error(t, err) - var iae *fleet.InvalidArgumentError - if !errors.As(err, &iae) { - t.Fatalf("expected InvalidArgumentError for same-team duplicate hash, got: %T: %v", err, err) - } + require.NoError(t, err) // Same hash on different team → allowed _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamB.ID, "c.sh", "title-c")) @@ -4444,13 +4439,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(nil, "global1.sh", "title-g1")) require.NoError(t, err) - // Global scope second time (duplicate hash) → reject + // Global scope, same hash under a different title → allowed _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(nil, "global2.sh", "title-g2")) - require.Error(t, err) - var iae2 *fleet.InvalidArgumentError - if !errors.As(err, &iae2) { - t.Fatalf("expected InvalidArgumentError for global duplicate hash, got: %T: %v", err, err) - } + require.NoError(t, err) // Test that binary packages (.pkg) with duplicate hash ARE allowed mkPkgPayload := func(teamID *uint, filename, title string) *fleet.UploadSoftwareInstallerPayload { @@ -4477,9 +4468,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPkgPayload(&teamA.ID, "pkg2.pkg", "title-pkg2")) require.NoError(t, err, "binary packages with same hash should be allowed on same team") - // Binary packages with same title on same team → reject + // Same title and hash on the same team → rejected by hash _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "a.sh", "title-a")) - require.ErrorContainsf(t, err, `"title-a" already exists with fleet "Team A".`, "expected existsError for same-team duplicate title, got: %T: %v", err, err) + require.ErrorContains(t, err, "same SHA-256 hash") } func testAddSoftwareTitleToMatchingSoftware(t *testing.T, ds *Datastore) { @@ -5719,7 +5710,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()}) require.NoError(t, err) - const conflictMsg = "already has an installer available for" + const conflictMsg = "already has an Apple App Store (VPP) on" // macOS installer conflicting with a VPP app on the same bundle id. test.CreateInsertGlobalVPPToken(t, ds) @@ -5777,7 +5768,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data }) require.NoError(t, err) - // macOS installer conflicting with the same installer at a newer version. + // macOS: a second version of the same title is allowed (multiple packages per title). _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "mac-base-storage", Filename: "mac-app.pkg", @@ -5806,9 +5797,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) - // Windows installer conflicting with the same Title at a newer version. + // Windows: a second version of the same title is allowed. _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "win-base-storage", Filename: "win-app.msi", @@ -5835,9 +5826,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) - // Windows installer conflicting on the upgrade code with a different Title. + // Windows: a second package matching the same upgrade code is allowed. const winUpgradeCode = "{ABCDEF12-3456-7890-ABCD-EF1234567890}" _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "win-uc-base-storage", @@ -5867,7 +5858,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) // Windows: existing installer has an upgrade code, new upload has the same // Title but no upgrade code. @@ -5898,7 +5889,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) // Reverse: existing installer has no upgrade code, new upload has the same // Title with an upgrade code. @@ -5929,9 +5920,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) - // Linux installer conflicting with the same Title at a newer version. + // Linux: a second version of the same title is allowed. _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "linux-base-storage", Filename: "linux-app.deb", @@ -5958,7 +5949,112 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) + + // Linux .deb: a duplicate content hash on the title is rejected. + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "linux-base-storage", + Filename: "linux-app-dup.deb", + Title: "Linux App", + Extension: "deb", + Source: "deb_packages", + Platform: "linux", + Version: "3.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.ErrorContains(t, err, "same SHA-256 hash") + + // Linux .rpm: a second build is allowed, a duplicate content hash is rejected. + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "rpm-base-storage", + Filename: "linux-app.rpm", + Title: "Linux RPM App", + Extension: "rpm", + Source: "rpm_packages", + Platform: "linux", + Version: "1.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.NoError(t, err) + + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "rpm-base-storage", + Filename: "linux-app-dup.rpm", + Title: "Linux RPM App", + Extension: "rpm", + Source: "rpm_packages", + Platform: "linux", + Version: "2.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.ErrorContains(t, err, "same SHA-256 hash") + + // Same title and version but different content is allowed (e.g. Arm vs Intel builds). + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "arch-arm-storage", + Filename: "arch-app-arm.pkg", + Title: "Arch App", + BundleIdentifier: "com.example.arch", + Extension: "pkg", + Source: "apps", + Platform: "darwin", + Version: "1.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.NoError(t, err) + + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "arch-intel-storage", + Filename: "arch-app-intel.pkg", + Title: "Arch App", + BundleIdentifier: "com.example.arch", + Extension: "pkg", + Source: "apps", + Platform: "darwin", + Version: "1.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.NoError(t, err) + + // A title holds at most maxPackagesPerTitle packages; the next one is rejected. + for i := range maxPackagesPerTitle { + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: fmt.Sprintf("limit-storage-%d", i), + Filename: fmt.Sprintf("limit-%d.msi", i), + Title: "Limit App", + Extension: "msi", + Source: "programs", + Platform: "windows", + Version: fmt.Sprintf("1.%d", i), + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.NoError(t, err) + } + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "limit-storage-extra", + Filename: "limit-extra.msi", + Title: "Limit App", + Extension: "msi", + Source: "programs", + Platform: "windows", + Version: "9.9", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.ErrorContains(t, err, fmt.Sprintf("already has %d packages", maxPackagesPerTitle)) } func testGetSoftwareTitlesForInstallAll(t *testing.T, ds *Datastore) { diff --git a/server/fleet/errors.go b/server/fleet/errors.go index ecba68a427c..c7c87ba7af1 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -37,6 +37,11 @@ var ( CantEnablePINRequiredIfDiskEncryptionEnabled = "Couldn't enable BitLocker PIN requirement, you must enable disk encryption first." CantResendAppleDeclarationProfilesMessage = "Can't resend declaration (DDM) profiles. Unlike configuration profiles (.mobileconfig), the host automatically checks in to get the latest DDM profiles." CantAddSoftwareConflictMessage = "Couldn't add software. %s already has an installer available for the %s fleet." + SoftwarePackageHashConflictMessage = "Couldn't add. %s package is already added (same SHA-256 hash)." + SoftwareAlreadyHasVPPAppMessage = "Couldn't add. %s already has an Apple App Store (VPP) on the %s fleet." + SoftwareAlreadyHasFleetMaintainedAppMessage = "Couldn't add. %s already has a Fleet-maintained app on the %s fleet." + SoftwareAlreadyHasPackageMessage = "Couldn't add. %s already has a software package on the %s fleet." + SoftwarePackageLimitMessage = "Couldn't add. %s already has %d packages. Before adding, delete one you no longer use." ConfigProfileLabelScopingPremiumCauseMsg = "Scoping configuration profiles with labels" ) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 31160180674..1ef04fbc34b 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12989,8 +12989,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD titleID, lblA.ID, lblA.Name) s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0) - // upload again fails - s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already has an installer available") + // upload again fails: identical bytes on the same title are a hash duplicate + s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "package is already added (same SHA-256 hash)") // update should succeed s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ @@ -13197,8 +13197,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD ) s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0) - // upload again fails - s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already has an installer available") + // upload again fails: identical bytes on the same title are a hash duplicate + s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "package is already added (same SHA-256 hash)") // download the installer r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", *payload.TeamID)) @@ -13310,8 +13310,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "fleet_name": null, "fleet_id": 0, "self_service": true, "software_title_id": %d}`, titleID), 0) - // upload again fails - s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already has an installer available") + // upload again fails: identical bytes on the same title are a hash duplicate + s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "package is already added (same SHA-256 hash)") // download the installer r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", 0)) @@ -27972,7 +27972,7 @@ func (s *integrationEnterpriseTestSuite) TestFMAVersionRollback() { // That logic isn't what we're trying to test here. _, _, err = s.ds.MatchOrCreateSoftwareInstaller(ctx, customPayload) assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf(fleet.CantAddSoftwareConflictMessage, customPayload.Title, team.Name)) + assert.Contains(t, err.Error(), fmt.Sprintf(fleet.SoftwareAlreadyHasFleetMaintainedAppMessage, customPayload.Title, team.Name)) // ========================================================================= // Section 2: UI single-add flow @@ -28095,7 +28095,7 @@ func (s *integrationEnterpriseTestSuite) TestFMAVersionRollback() { http.StatusConflict, ) errMsg := extractServerErrorText(conflictResp.Body) - require.Contains(t, errMsg, "already has an installer available", + require.Contains(t, errMsg, "already has a software package", "error should mention the conflict with the existing custom installer") // Confirm the FMA was NOT added — only the original custom installer exists. diff --git a/server/service/integration_vpp_install_test.go b/server/service/integration_vpp_install_test.go index e3e966bcb0a..acae1dc7127 100644 --- a/server/service/integration_vpp_install_test.go +++ b/server/service/integration_vpp_install_test.go @@ -1467,7 +1467,7 @@ func (s *integrationMDMTestSuite) TestSoftwareTitleVPPAppSoftwarePackageConflict Title: "DummyApp", TeamID: &team.ID, } - s.uploadSoftwareInstaller(t, pkgDummy, http.StatusConflict, "DummyApp already has an installer available for the Team 1 fleet.") + s.uploadSoftwareInstaller(t, pkgDummy, http.StatusConflict, "DummyApp already has an Apple App Store (VPP) on the Team 1 fleet.") // Add VPP app 2 with bundle ID com.example.noversion (conflicts with NoVersion) vppApp2 := &fleet.VPPApp{ @@ -2125,7 +2125,7 @@ func (s *integrationMDMTestSuite) TestInHouseAppVPPConflict() { s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{ Filename: "ipa_test.ipa", TeamID: &team2.ID, - }, http.StatusConflict, "already has an installer available for the IPA Conflict Team 2 fleet.") + }, http.StatusConflict, "already has an Apple App Store (VPP) on the IPA Conflict Team 2 fleet.") // Test Case 3: Verify "No team" works correctly s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{ From 66bbfe82954690a913d5a51edee8cb9fce8c1595 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 16:44:15 -0400 Subject: [PATCH 3/9] First-added reads, list-all packages method, and multi-active regression fixes (#48396) GetSoftwareInstallerMetadataByTeamAndTitleID now returns the first-added active package. Add GetSoftwarePackagesByTeamAndTitleID returning every active package on a title with per-package label scope, for the API and precedence sub-issues. Fix the regressions that multiple active rows cause: SoftwareTitleByID counts use COUNT(DISTINCT) so the installer/VPP/in-house cross-join no longer inflates them, both ListSoftwareTitles query paths join only the first-added active installer so a title appears once (hash and package-name filters use EXISTS over all packages), and the edit path no longer rejects a title with more than one package. --- ee/server/service/software_installers.go | 5 +- ...29163945_MultipleCustomPackagesPerTitle.go | 21 ++-- ...945_MultipleCustomPackagesPerTitle_test.go | 12 +- server/datastore/mysql/software_installers.go | 119 +++++++++++++----- .../mysql/software_installers_test.go | 56 ++++++++- server/datastore/mysql/software_titles.go | 22 ++-- server/fleet/datastore.go | 4 + server/mock/datastore_mock.go | 12 ++ 8 files changed, 194 insertions(+), 57 deletions(-) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index f7ddf9c7b0b..5bc895fa443 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -411,8 +411,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return svc.updateInHouseAppInstaller(ctx, payload, vc, teamName, software) } - // TODO when we start supporting multiple installers per title X team, need to rework how we determine installer to edit - if software.SoftwareInstallersCount != 1 { + // With more than one installer on the title, this edits the first-added one. + // Choosing a specific package to edit is handled by the precedence work. + if software.SoftwareInstallersCount < 1 { return nil, &fleet.BadRequestError{ Message: "There are no software installers defined yet for this title and team. Please add an installer instead of attempting to edit.", } diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go index a775973f723..2f7eb2cad5c 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -10,13 +10,13 @@ func init() { } func Up_20260629163945(tx *sql.Tx) error { - // A title may now have more than one package. dedup_token makes uniqueness - // regime-aware: custom rows resolve it to storage_id so they dedupe by content hash - // (Arm and Intel of the same version coexist, identical bytes are rejected), while - // FMA rows resolve it to version so version-uniqueness is unchanged and the same - // bytes can back several versions. A (team, title) is single-regime, and a hash - // never equals a version string, so the two token spaces don't collide. The column - // is VIRTUAL so the add is in-place; its only consumer is the unique key below. + // A title may now have more than one package. dedup_token makes uniqueness depend on + // the kind of package: custom rows resolve it to storage_id so they dedupe by content + // hash, so Arm and Intel of one version coexist while identical bytes are rejected. + // FMA rows resolve it to version, so version-uniqueness is unchanged and the same + // bytes can back several versions. A title holds only one kind, and a hash never + // equals a version string, so the two token spaces don't collide. The column is + // VIRTUAL so the add is in-place and its only consumer is the unique key below. if _, err := tx.Exec(` ALTER TABLE software_installers ADD COLUMN dedup_token VARCHAR(255) @@ -27,10 +27,9 @@ func Up_20260629163945(tx *sql.Tx) error { // Where a (global_or_team_id, title_id, dedup_token) has more than one row, keep the // first-added (smallest id) as the survivor and delete the rest, so the unique key - // below can be added. This collapses the duplicate-active custom rows from the P1 bug - // and any custom same-hash duplicates. FMA rows already satisfy version-uniqueness. - // Re-point policies off the deleted rows first, since policies.software_installer_id - // is RESTRICT. + // below can be added. This collapses duplicate-active custom rows and any custom + // same-hash duplicates. FMA rows already satisfy version-uniqueness. Re-point policies + // off the deleted rows first, since policies.software_installer_id is RESTRICT. const dupGroups = ` SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id FROM software_installers diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go index 5b42c76c04d..be01d0f2ca5 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -24,7 +24,7 @@ func TestUp_20260629163945(t *testing.T) { return execNoErrLastID(t, db, `INSERT INTO script_contents (contents, md5_checksum) VALUES ('#!/bin/sh', UNHEX(MD5(?)))`, seed) } - // teamID nil means no-team (global_or_team_id 0); fmaID nil means a custom package. + // teamID nil means no-team with global_or_team_id 0, fmaID nil means a custom package. args := func(titleID int64, teamID *int64, platform string, version string, storage string, fmaID *int64, active int) []any { script := insertScript(storage + version) var globalOrTeamID int64 @@ -96,7 +96,7 @@ func TestUp_20260629163945(t *testing.T) { applyNext(t, db) - // The version key is gone, replaced by the regime-aware dedup key. + // The version key is gone, replaced by the dedup_token key. require.Zero(t, countRows(` SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'software_installers' @@ -127,7 +127,7 @@ func TestUp_20260629163945(t *testing.T) { require.Equal(t, 1, countRows(`SELECT is_active FROM software_installers WHERE id = ?`, keepB)) require.Equal(t, 1, countRows(`SELECT is_active FROM software_installers WHERE id = ?`, keepC)) - // The survivor keeps its label and category; the deleted row's cascade away. + // The survivor keeps its label and category. The deleted row's cascade away. require.Equal(t, 1, countRows(`SELECT COUNT(*) FROM software_installer_labels WHERE software_installer_id = ?`, keepB)) require.Equal(t, 1, countRows(`SELECT COUNT(*) FROM software_installer_software_categories WHERE software_installer_id = ?`, keepB)) require.Zero(t, countRows(`SELECT COUNT(*) FROM software_installer_labels WHERE software_installer_id = ?`, dupB)) @@ -141,9 +141,9 @@ func TestUp_20260629163945(t *testing.T) { // FMA same-hash-different-version rows both survive. require.Equal(t, []int64{fmaOld, fmaActive}, remainingIDs(titleD)) - // New key behavior. Custom same-version-different-hash (Arm vs Intel) is accepted; - // these could not be seeded before the migration because the old version key blocked - // two rows sharing a version. + // New key behavior. Custom same-version-different-hash is accepted, and these could not + // be seeded before the migration because the old version key blocked two rows sharing a + // version. titleE := insertTitle("AppE", "apps") require.NoError(t, tryInsertInstaller(titleE, nil, "darwin", "9.0", "hash-e1", nil, 1)) require.NoError(t, tryInsertInstaller(titleE, nil, "darwin", "9.0", "hash-e2", nil, 1)) diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 878ab3cbd31..b3a060a3f1f 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -1280,7 +1280,7 @@ FROM WHERE si.title_id = ? AND si.global_or_team_id = ? AND si.is_active = 1 -ORDER BY si.uploaded_at DESC, si.id DESC +ORDER BY si.id ASC LIMIT 1`, scriptContentsSelect, scriptContentsFrom) @@ -1300,37 +1300,11 @@ LIMIT 1`, // TODO: do we want to include labels on other queries that return software installer metadata // (e.g., GetSoftwareInstallerMetadataByID)? - labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID, softwareTypeInstaller) + dest.LabelsExcludeAny, dest.LabelsIncludeAny, dest.LabelsIncludeAll, err = ds.scopedSoftwareInstallerLabels(ctx, dest.InstallerID) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get software installer labels") - } - var exclAny, inclAny, inclAll []fleet.SoftwareScopeLabel - for _, l := range labels { - switch { - case l.Exclude && !l.RequireAll: - exclAny = append(exclAny, l) - 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)) - } + return nil, err } - 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 { return nil, ctxerr.Wrap(ctx, err, "getting categories for software installer metadata") @@ -1366,6 +1340,91 @@ LIMIT 1`, return &dest, nil } +func (ds *Datastore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + const query = ` +SELECT + si.id, + si.team_id, + si.title_id, + si.storage_id, + si.fleet_maintained_app_id, + si.package_ids, + si.upgrade_code, + si.filename, + si.extension, + si.version, + si.platform, + si.install_script_content_id, + si.pre_install_query, + si.post_install_script_content_id, + si.uninstall_script_content_id, + si.uploaded_at, + si.self_service, + si.url, + COALESCE(st.name, '') AS software_title, + COALESCE(st.bundle_identifier, '') AS bundle_identifier, + si.patch_query +FROM + software_installers si + JOIN software_titles st ON st.id = si.title_id +WHERE + si.title_id = ? AND si.global_or_team_id = ? + AND si.is_active = 1 +ORDER BY si.id ASC` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + var packages []*fleet.SoftwareInstaller + err := sqlx.SelectContext(ctx, ds.reader(ctx), &packages, query, titleID, tmID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "list software packages by team and title") + } + + for _, pkg := range packages { + pkg.LabelsExcludeAny, pkg.LabelsIncludeAny, pkg.LabelsIncludeAll, err = ds.scopedSoftwareInstallerLabels(ctx, pkg.InstallerID) + if err != nil { + return nil, err + } + } + + return packages, nil +} + +func (ds *Datastore) scopedSoftwareInstallerLabels(ctx context.Context, installerID uint) (excludeAny []fleet.SoftwareScopeLabel, includeAny []fleet.SoftwareScopeLabel, includeAll []fleet.SoftwareScopeLabel, err error) { + labels, err := ds.getSoftwareInstallerLabels(ctx, installerID, softwareTypeInstaller) + if err != nil { + return nil, nil, nil, ctxerr.Wrap(ctx, err, "get software installer labels") + } + + for _, l := range labels { + switch { + case l.Exclude && !l.RequireAll: + excludeAny = append(excludeAny, l) + case !l.Exclude && l.RequireAll: + includeAll = append(includeAll, l) + case !l.Exclude && !l.RequireAll: + includeAny = append(includeAny, l) + default: + ds.logger.WarnContext(ctx, "software installer has an unsupported label scope", "installer_id", installerID, "invalid_label", fmt.Sprintf("%#v", l)) + } + } + + var scopes int + for _, set := range [][]fleet.SoftwareScopeLabel{excludeAny, includeAny, includeAll} { + if len(set) > 0 { + scopes++ + } + } + if scopes > 1 { + ds.logger.WarnContext(ctx, "software installer has more than one scope of labels", "installer_id", installerID, "include_any", fmt.Sprintf("%v", includeAny), "exclude_any", fmt.Sprintf("%v", excludeAny), "include_all", fmt.Sprintf("%v", includeAll)) + } + + return excludeAny, includeAny, includeAll, nil +} + func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint, softwareType softwareType) ([]fleet.SoftwareScopeLabel, error) { query := fmt.Sprintf(` SELECT @@ -4196,7 +4255,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay func (ds *Datastore) checkFleetMaintainedAppExists(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (bool, error) { // look for the other kind of package on the title: an FMA when adding a custom // package, a custom package when adding an FMA. Matched by bundle identifier on - // macOS and by name or upgrade code on Windows; FMAs only exist on those platforms. + // macOS and by name or upgrade code on Windows. FMAs only exist on those platforms. wantFMA := payload.FleetMaintainedAppID == nil var stmt string var args []any diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 3677f72cbe9..36437ee8b3b 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -40,6 +40,7 @@ func TestSoftwareInstallers(t *testing.T) { {"BatchSetSoftwareInstallersWithUpgradeCodes", testBatchSetSoftwareInstallersWithUpgradeCodes}, {"GetSoftwareInstallersPendingDeletion", testGetSoftwareInstallersPendingDeletion}, {"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID}, + {"GetSoftwarePackagesByTeamAndTitleID", testGetSoftwarePackagesByTeamAndTitleID}, {"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers}, {"DeleteSoftwareInstallers", testDeleteSoftwareInstallers}, {"testDeletePendingSoftwareInstallsForPolicy", testDeletePendingSoftwareInstallsForPolicy}, @@ -4393,6 +4394,59 @@ func testSoftwareTitleDisplayName(t *testing.T, ds *Datastore) { require.Contains(t, names, "ipa_foo") } +func testGetSoftwarePackagesByTeamAndTitleID(t *testing.T, ds *Datastore) { + ctx := context.Background() + user := test.NewUser(t, ds, "Pkg Lister", "pkglister@example.com", true) + team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()}) + require.NoError(t, err) + + lbl, err := ds.NewLabel(ctx, &fleet.Label{Name: t.Name() + "-lbl", Query: "SELECT 1"}) + require.NoError(t, err) + + mk := func(storage string, filename string, labels *fleet.LabelIdentsWithScope) *fleet.UploadSoftwareInstallerPayload { + return &fleet.UploadSoftwareInstallerPayload{ + StorageID: storage, + Filename: filename, + Title: "Multi App", + BundleIdentifier: "com.example.multi", + Extension: "pkg", + Source: "apps", + Platform: "darwin", + Version: "1.0", + UserID: user.ID, + ValidatedLabels: labels, + TeamID: &team.ID, + } + } + + // Two custom packages of the same version but different content on one title; only + // the first is scoped to a label. + withLabel := &fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{lbl.Name: {LabelID: lbl.ID, LabelName: lbl.Name}}, + } + _, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, mk("multi-1", "multi-1.pkg", withLabel)) + require.NoError(t, err) + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mk("multi-2", "multi-2.pkg", &fleet.LabelIdentsWithScope{})) + require.NoError(t, err) + + pkgs, err := ds.GetSoftwarePackagesByTeamAndTitleID(ctx, &team.ID, titleID) + require.NoError(t, err) + require.Len(t, pkgs, 2) + // returned first-added first, each with its own label scope + require.Equal(t, "multi-1.pkg", pkgs[0].Name) + require.Equal(t, "multi-1", pkgs[0].StorageID) + require.Len(t, pkgs[0].LabelsIncludeAny, 1) + require.Equal(t, lbl.ID, pkgs[0].LabelsIncludeAny[0].LabelID) + require.Equal(t, "multi-2.pkg", pkgs[1].Name) + require.Empty(t, pkgs[1].LabelsIncludeAny) + + // a title with no packages returns none + none, err := ds.GetSoftwarePackagesByTeamAndTitleID(ctx, &team.ID, titleID+1000) + require.NoError(t, err) + require.Empty(t, none) +} + func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore) { ctx := context.Background() @@ -6026,7 +6080,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data }) require.NoError(t, err) - // A title holds at most maxPackagesPerTitle packages; the next one is rejected. + // A title holds at most maxPackagesPerTitle packages, so the next one is rejected. for i := range maxPackagesPerTitle { _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: fmt.Sprintf("limit-storage-%d", i), diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 8c4f1036f62..c412aab345d 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -68,9 +68,9 @@ SELECT st.upgrade_code, COALESCE(sthc.hosts_count, 0) AS hosts_count, MAX(sthc.updated_at) AS counts_updated_at, - COUNT(si.id) as software_installers_count, - COUNT(vat.adam_id) AS vpp_apps_count, - COUNT(iha.id) AS in_house_apps_count, + COUNT(DISTINCT si.id) as software_installers_count, + COUNT(DISTINCT vat.adam_id) AS vpp_apps_count, + COUNT(DISTINCT iha.id) AS in_house_apps_count, %s vap.icon_url AS icon_url FROM software_titles st @@ -632,7 +632,10 @@ SELECT {{end}} FROM software_titles st {{if hasTeamID .}} - LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = {{teamID .}} AND si.is_active = TRUE + LEFT JOIN software_installers si ON si.id = ( + SELECT MIN(si2.id) FROM software_installers si2 + WHERE si2.title_id = st.id AND si2.global_or_team_id = {{teamID .}} AND si2.is_active = TRUE + ) LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND iha.global_or_team_id = {{teamID .}} LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND {{yesNo .PackagesOnly "FALSE" "TRUE"}} LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND @@ -682,10 +685,10 @@ WHERE {{end}} {{end}} {{if and (hasTeamID $) $.HashSHA256}} - {{$additionalWhere = printf "%s AND si.storage_id = ?" $additionalWhere}} + {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers sif WHERE sif.title_id = st.id AND sif.global_or_team_id = %d AND sif.is_active = TRUE AND sif.storage_id = ?)" $additionalWhere (teamID $)}} {{end}} {{if and (hasTeamID $) $.PackageName}} - {{$additionalWhere = printf "%s AND si.filename = ?" $additionalWhere}} + {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers sif WHERE sif.title_id = st.id AND sif.global_or_team_id = %d AND sif.is_active = TRUE AND sif.filename = ?)" $additionalWhere (teamID $)}} {{end}} {{$additionalWhere}} {{end}} @@ -936,8 +939,13 @@ func buildOptimizedListSoftwareTitlesSQL(opts fleet.SoftwareTitleListOptions) st innerSQL, teamID, globalStats) if hasTeamID { + // A title can hold several active installers. Join only the first-added one so the + // title appears once with its primary package. outerSQL += fmt.Sprintf(` - LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = %[1]d AND si.is_active = TRUE + LEFT JOIN software_installers si ON si.id = ( + SELECT MIN(si2.id) FROM software_installers si2 + WHERE si2.title_id = st.id AND si2.global_or_team_id = %[1]d AND si2.is_active = TRUE + ) LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND iha.global_or_team_id = %[1]d LEFT JOIN vpp_apps vap ON vap.title_id = st.id LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 0295833e1d1..309c0de6506 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2689,6 +2689,10 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) + // GetSoftwarePackagesByTeamAndTitleID returns every active package on the team + // and title, ordered first-added first, each with its label scope. + GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*SoftwareInstaller, error) + // GetFleetMaintainedVersionsByTitleID returns all cached versions of a // fleet-maintained app for the given title and team. If byVersion is true // the versions will be sorted by their version semver or string. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index faac330823d..3fb5d4e48ef 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1576,6 +1576,8 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type GetSoftwarePackagesByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) + type GetFleetMaintainedVersionsByTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) type ListFleetMaintainedAppActiveInstallersFunc func(ctx context.Context) ([]fleet.FMAAutoUpdateCandidate, error) @@ -4467,6 +4469,9 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFunc + GetSoftwarePackagesByTeamAndTitleIDFuncInvoked bool + GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFuncInvoked bool @@ -10745,6 +10750,13 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + s.mu.Lock() + s.GetSoftwarePackagesByTeamAndTitleIDFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwarePackagesByTeamAndTitleIDFunc(ctx, teamID, titleID) +} + func (s *DataStore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) { s.mu.Lock() s.GetFleetMaintainedVersionsByTitleIDFuncInvoked = true From c1b8945d934ec01586655d5e8c19c8d4462ff46a Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 17:17:45 -0400 Subject: [PATCH 4/9] Use defer for rows.Close in migration test and reword list-all doc (#48396) --- .../20260629163945_MultipleCustomPackagesPerTitle_test.go | 4 ++-- server/fleet/datastore.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go index be01d0f2ca5..f15eb7ee69b 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -50,13 +50,13 @@ func TestUp_20260629163945(t *testing.T) { var ids []int64 r, err := db.Query(`SELECT id FROM software_installers WHERE title_id = ? ORDER BY id`, titleID) require.NoError(t, err) + defer r.Close() for r.Next() { var id int64 require.NoError(t, r.Scan(&id)) ids = append(ids, id) } require.NoError(t, r.Err()) - require.NoError(t, r.Close()) return ids } @@ -108,13 +108,13 @@ func TestUp_20260629163945(t *testing.T) { AND index_name = 'idx_software_installers_dedup' ORDER BY seq_in_index`) require.NoError(t, err) + defer rows.Close() for rows.Next() { var col string require.NoError(t, rows.Scan(&col)) dedupCols = append(dedupCols, col) } require.NoError(t, rows.Err()) - require.NoError(t, rows.Close()) require.Equal(t, []string{"global_or_team_id", "title_id", "dedup_token"}, dedupCols) // Single-package title untouched. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 309c0de6506..654472ec6d2 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2689,8 +2689,8 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) - // GetSoftwarePackagesByTeamAndTitleID returns every active package on the team - // and title, ordered first-added first, each with its label scope. + // GetSoftwarePackagesByTeamAndTitleID returns every active package for the given + // title and team, ordered first-added first, each with its label scope. GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*SoftwareInstaller, error) // GetFleetMaintainedVersionsByTitleID returns all cached versions of a From 1668c653b694f16e2ea3c7ac9cdce286810f3fe7 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 17:34:49 -0400 Subject: [PATCH 5/9] Test multiple active packages collapse to one title with correct count (#48396) --- ...29163945_MultipleCustomPackagesPerTitle.go | 6 +- .../mysql/software_installers_test.go | 33 +++++++---- .../datastore/mysql/software_titles_test.go | 53 ++++++++++++++++++ .../select_software_titles_sql_fixture.gz | Bin 35272 -> 36704 bytes 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go index 2f7eb2cad5c..c661137a198 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -16,10 +16,12 @@ func Up_20260629163945(tx *sql.Tx) error { // FMA rows resolve it to version, so version-uniqueness is unchanged and the same // bytes can back several versions. A title holds only one kind, and a hash never // equals a version string, so the two token spaces don't collide. The column is - // VIRTUAL so the add is in-place and its only consumer is the unique key below. + // VIRTUAL so the add is in-place and its only consumer is the unique key below. Its + // collation is pinned to match storage_id and version so the migration path does not + // inherit the server default collation that fresh installs never see. if _, err := tx.Exec(` ALTER TABLE software_installers - ADD COLUMN dedup_token VARCHAR(255) + ADD COLUMN dedup_token VARCHAR(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (IF(fleet_maintained_app_id IS NULL, storage_id, version)) VIRTUAL `); err != nil { return fmt.Errorf("adding dedup_token column: %w", err) diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 36437ee8b3b..d83d3c156c7 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -3728,16 +3728,28 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) { // Simulate the scenario from issue #42260: an FMA version update creates // a second row with the same storage_id but different version and is_active = 0. + // FMA rows dedupe by version, so the same bytes can back more than one version. // GetTeamsWithInstallerByHash must only return the active row. ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, ` + res, err := q.ExecContext(ctx, ` + INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier) + VALUES ('installer1', 'installer1/darwin', 'darwin', 'com.installer1.fma')`) + if err != nil { + return err + } + fmaID, err := res.LastInsertId() + if err != nil { + return err + } + _, err = q.ExecContext(ctx, ` INSERT INTO software_installers (team_id, global_or_team_id, storage_id, filename, extension, version, platform, title_id, - install_script_content_id, uninstall_script_content_id, is_active, url, package_ids, patch_query) + install_script_content_id, uninstall_script_content_id, is_active, url, package_ids, patch_query, + fleet_maintained_app_id) SELECT team_id, global_or_team_id, storage_id, filename, extension, 'old_version', platform, title_id, - install_script_content_id, uninstall_script_content_id, 0, url, package_ids, patch_query + install_script_content_id, uninstall_script_content_id, 0, url, package_ids, patch_query, ? FROM software_installers WHERE id = ? - `, installer1NoTeam) + `, fmaID, installer1NoTeam) return err }) @@ -5471,9 +5483,10 @@ func testCustomToFMAInstallerReplacement(t *testing.T, ds *Datastore) { }) require.Equal(t, initialDisplayNameID, afterDisplayNameID, "display_name row should be upserted in place, not deleted and re-inserted") - // Same-version case: custom installer and incoming FMA share a version - // string. ON DUPLICATE KEY UPDATE on (team, title, version) upserts in - // place; the row must be converted to FMA, not deleted. + // Same-version case: the custom installer and the incoming FMA share a + // version string. Converting a custom package to an FMA replaces the row and + // re-points its FKs (same as the different-version case above), leaving the + // FMA as the single active row for the title. team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "team_custom_to_fma_same_version"}) require.NoError(t, err) @@ -5529,7 +5542,7 @@ func testCustomToFMAInstallerReplacement(t *testing.T, ds *Datastore) { tmFilter2 := fleet.TeamFilter{User: test.UserAdmin, TeamID: new(team2.ID)} titles2, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: new(team2.ID), Platform: "darwin", AvailableForInstall: true}, tmFilter2) require.NoError(t, err) - require.Len(t, titles2, 1, "exactly one installer row should remain after same-version custom\u2192FMA upsert") + require.Len(t, titles2, 1, "exactly one installer row should remain after same-version custom\u2192FMA conversion") var installerRows []struct { ID uint `db:"id"` @@ -5543,8 +5556,8 @@ func testCustomToFMAInstallerReplacement(t *testing.T, ds *Datastore) { `, team2.ID, titles2[0].ID) }) require.Len(t, installerRows, 1) - require.Equal(t, customInstallerID2, installerRows[0].ID, "row should be updated in place, not deleted+re-inserted") - require.NotNil(t, installerRows[0].FMAID, "row should have been converted to FMA via ON DUPLICATE KEY UPDATE") + require.NotEqual(t, customInstallerID2, installerRows[0].ID, "custom row should be replaced by the FMA row") + require.NotNil(t, installerRows[0].FMAID, "row should have been converted to FMA") require.Equal(t, fma2.ID, *installerRows[0].FMAID) require.True(t, installerRows[0].IsActive) } diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index adb9f041faa..8d3b0112e3e 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -45,6 +45,7 @@ func TestSoftwareTitles(t *testing.T) { {"ListSoftwareTitlesByPlatform", testListSoftwareTitlesByPlatform}, {"UpdateAutoUpdateConfig", testUpdateAutoUpdateConfig}, {"ListSoftwareTitlesSortByDisplayName", testListSoftwareTitlesSortByDisplayName}, + {"ListSoftwareTitlesMultiplePackages", testListSoftwareTitlesMultiplePackages}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -891,6 +892,58 @@ func titleByName(titles []fleet.SoftwareTitleListResult, name string) fleet.Soft return fleet.SoftwareTitleListResult{} } +func testListSoftwareTitlesMultiplePackages(t *testing.T, ds *Datastore) { + ctx := context.Background() + user := test.NewUser(t, ds, "Multi", "multi@example.com", true) + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "multi-pkg-team"}) + require.NoError(t, err) + + mk := func(storage string, filename string) *fleet.UploadSoftwareInstallerPayload { + return &fleet.UploadSoftwareInstallerPayload{ + Title: "Multi App", + Source: "apps", + BundleIdentifier: "com.example.multi", + Platform: "darwin", + Extension: "pkg", + Version: "1.0", + InstallScript: "echo", + Filename: filename, + StorageID: storage, + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + } + } + + // two active packages on one title (same version, different content) + _, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, mk("multi-a", "a.pkg")) + require.NoError(t, err) + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mk("multi-b", "b.pkg")) + require.NoError(t, err) + + adminFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}} + countMultiApp := func(opts fleet.SoftwareTitleListOptions) int { + titles, _, _, err := ds.ListSoftwareTitles(ctx, opts, adminFilter) + require.NoError(t, err) + var n int + for _, tl := range titles { + if tl.Name == "Multi App" { + n++ + } + } + return n + } + + // the title appears once on the optimized path (no filters) and the filtered path + require.Equal(t, 1, countMultiApp(fleet.SoftwareTitleListOptions{TeamID: &team.ID})) + require.Equal(t, 1, countMultiApp(fleet.SoftwareTitleListOptions{TeamID: &team.ID, ListOptions: fleet.ListOptions{MatchQuery: "Multi App"}})) + + // the installer count reflects both packages + title, err := ds.SoftwareTitleByID(ctx, titleID, &team.ID, adminFilter) + require.NoError(t, err) + require.Equal(t, 2, title.SoftwareInstallersCount) +} + func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz b/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz index 082d2e213b5c4edf440fa8a3bc865c74698a8499..a4cb086f234874c22cf1b156daf45cc8bc3267f0 100644 GIT binary patch literal 36704 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 literal 35272 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�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%0DYuDU+07wYGZ>O9IkCY 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 Mi^)$db^*};0m7|gasU7T From 9db7fc098659d9d28277dae42390db9c273c3074 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Tue, 30 Jun 2026 13:23:29 -0400 Subject: [PATCH 6/9] Restore team-wide script dedup and apply review cleanups (#48396) --- ...29163945_MultipleCustomPackagesPerTitle.go | 26 +++++------ server/datastore/mysql/software_installers.go | 42 +++++++++++------- .../mysql/software_installers_test.go | 43 +++++++++---------- server/datastore/mysql/software_titles.go | 4 +- .../datastore/mysql/software_titles_test.go | 25 +++++------ server/fleet/errors.go | 10 ++--- server/fleet/software_installer.go | 3 ++ 7 files changed, 79 insertions(+), 74 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go index c661137a198..792d2a3a121 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -10,15 +10,11 @@ func init() { } func Up_20260629163945(tx *sql.Tx) error { - // A title may now have more than one package. dedup_token makes uniqueness depend on - // the kind of package: custom rows resolve it to storage_id so they dedupe by content - // hash, so Arm and Intel of one version coexist while identical bytes are rejected. - // FMA rows resolve it to version, so version-uniqueness is unchanged and the same - // bytes can back several versions. A title holds only one kind, and a hash never - // equals a version string, so the two token spaces don't collide. The column is - // VIRTUAL so the add is in-place and its only consumer is the unique key below. Its - // collation is pinned to match storage_id and version so the migration path does not - // inherit the server default collation that fresh installs never see. + // A title can now hold several packages. dedup_token drives the new unique key. Custom + // rows resolve it to storage_id so they dedupe by content hash, letting different builds of + // one version coexist. FMA rows resolve it to version, leaving the per-version rows that + // back version pinning unchanged. VIRTUAL keeps the add in-place. The collation is pinned + // to match storage_id and version so the migration matches what fresh installs get. if _, err := tx.Exec(` ALTER TABLE software_installers ADD COLUMN dedup_token VARCHAR(255) COLLATE utf8mb4_unicode_ci @@ -27,11 +23,9 @@ func Up_20260629163945(tx *sql.Tx) error { return fmt.Errorf("adding dedup_token column: %w", err) } - // Where a (global_or_team_id, title_id, dedup_token) has more than one row, keep the - // first-added (smallest id) as the survivor and delete the rest, so the unique key - // below can be added. This collapses duplicate-active custom rows and any custom - // same-hash duplicates. FMA rows already satisfy version-uniqueness. Re-point policies - // off the deleted rows first, since policies.software_installer_id is RESTRICT. + // Collapse rows that would violate the new key: keep the lowest id per group and delete + // the rest. Re-point policies off the deleted rows first, since + // policies.software_installer_id is RESTRICT. const dupGroups = ` SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id FROM software_installers @@ -47,7 +41,7 @@ func Up_20260629163945(tx *sql.Tx) error { AND si.title_id = dup.title_id AND si.dedup_token = dup.dedup_token SET p.software_installer_id = dup.keep_id - WHERE si.id <> dup.keep_id`, dupGroups)); err != nil { + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { return fmt.Errorf("re-pointing policies off duplicate installers: %w", err) } @@ -57,7 +51,7 @@ func Up_20260629163945(tx *sql.Tx) error { ON si.global_or_team_id = dup.global_or_team_id AND si.title_id = dup.title_id AND si.dedup_token = dup.dedup_token - WHERE si.id <> dup.keep_id`, dupGroups)); err != nil { + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { return fmt.Errorf("deleting duplicate installers: %w", err) } diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index b3a060a3f1f..29f3d50b242 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -198,7 +198,8 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, errors.New("validated labels must not be nil") } - if err := ds.checkSoftwareConflictsByIdentifier(ctx, payload); err != nil { + err = ds.checkSoftwareConflictsByIdentifier(ctx, payload) + if err != nil { return 0, 0, err } @@ -229,6 +230,23 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID") } + // Script packages dedupe by content team-wide: identical bytes are the same script, so + // they can't be added under a different title. Same-title duplicates are already caught + // by the per-title hash check. Binary installers can legitimately ship the same content + // with different install scripts, so they are not deduped this way. + if payload.StorageID != "" && fleet.IsScriptPackage(payload.Extension) { + teamsByHash, err := ds.GetTeamsWithInstallerByHash(ctx, payload.StorageID, "") + if err != nil { + return 0, 0, ctxerr.Wrap(ctx, err, "check duplicate installer by hash") + } + if _, exists := teamsByHash[ptr.ValOrZero(payload.TeamID)]; exists { + return 0, 0, fleet.NewInvalidArgumentError( + "software", + "Couldn't add software. An installer with identical contents already exists on this fleet.", + ) + } + } + if err := ds.addSoftwareTitleToMatchingSoftware(ctx, titleID, payload); err != nil { return 0, 0, ctxerr.Wrap(ctx, err, "add software title to matching software") } @@ -1372,13 +1390,8 @@ WHERE AND si.is_active = 1 ORDER BY si.id ASC` - var tmID uint - if teamID != nil { - tmID = *teamID - } - var packages []*fleet.SoftwareInstaller - err := sqlx.SelectContext(ctx, ds.reader(ctx), &packages, query, titleID, tmID) + err := sqlx.SelectContext(ctx, ds.reader(ctx), &packages, query, titleID, ptr.ValOrZero(teamID)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "list software packages by team and title") } @@ -4148,8 +4161,6 @@ LIMIT 1` return &installer, nil } -const maxPackagesPerTitle = 10 - func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error { conflict := func(message string) error { teamName, err := ds.getTeamName(ctx, payload.TeamID) @@ -4218,7 +4229,8 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return err } - // check if a custom package with the same hash exists + // A package can't repeat the same bytes within its title. Scripts also dedupe + // team-wide in MatchOrCreateSoftwareInstaller. var dup bool err = sqlx.GetContext(ctx, ds.reader(ctx), &dup, ` SELECT EXISTS ( @@ -4234,7 +4246,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay }, "duplicate package by hash") } - // a title holds at most maxPackagesPerTitle custom packages + // a title holds at most fleet.MaxPackagesPerTitle custom packages var count int err = sqlx.GetContext(ctx, ds.reader(ctx), &count, ` SELECT COUNT(*) FROM software_installers @@ -4242,9 +4254,9 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay if err != nil { return ctxerr.Wrap(ctx, err, "count packages on the title") } - if count >= maxPackagesPerTitle { + if count >= fleet.MaxPackagesPerTitle { return ctxerr.Wrap(ctx, fleet.ConflictError{ - Message: fmt.Sprintf(fleet.SoftwarePackageLimitMessage, payload.Title, maxPackagesPerTitle), + Message: fmt.Sprintf(fleet.SoftwarePackageLimitMessage, payload.Title, fleet.MaxPackagesPerTitle), }, "package limit reached") } } @@ -4276,10 +4288,10 @@ func (ds *Datastore) checkFleetMaintainedAppExists(ctx context.Context, payload SELECT 1 FROM software_installers si JOIN software_titles st ON st.id = si.title_id - WHERE si.global_or_team_id = ? AND st.source = ? AND (st.name = ? OR (? != '' AND st.upgrade_code = ?)) + WHERE si.global_or_team_id = ? AND st.source = ? AND (st.name = ? OR (st.upgrade_code != '' AND st.upgrade_code = ?)) AND (si.fleet_maintained_app_id IS NOT NULL) = ? )` - args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.Title, payload.UpgradeCode, payload.UpgradeCode, wantFMA} + args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.Title, payload.UpgradeCode, wantFMA} default: return false, nil } diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index d83d3c156c7..57c68f06327 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -3730,18 +3730,15 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) { // a second row with the same storage_id but different version and is_active = 0. // FMA rows dedupe by version, so the same bytes can back more than one version. // GetTeamsWithInstallerByHash must only return the active row. + fma, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{ + Name: "installer1", + Slug: "installer1/darwin", + Platform: "darwin", + UniqueIdentifier: "com.installer1.fma", + }) + require.NoError(t, err) ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - res, err := q.ExecContext(ctx, ` - INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier) - VALUES ('installer1', 'installer1/darwin', 'darwin', 'com.installer1.fma')`) - if err != nil { - return err - } - fmaID, err := res.LastInsertId() - if err != nil { - return err - } - _, err = q.ExecContext(ctx, ` + _, err := q.ExecContext(ctx, ` INSERT INTO software_installers (team_id, global_or_team_id, storage_id, filename, extension, version, platform, title_id, install_script_content_id, uninstall_script_content_id, is_active, url, package_ids, patch_query, @@ -3749,7 +3746,7 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) { SELECT team_id, global_or_team_id, storage_id, filename, extension, 'old_version', platform, title_id, install_script_content_id, uninstall_script_content_id, 0, url, package_ids, patch_query, ? FROM software_installers WHERE id = ? - `, fmaID, installer1NoTeam) + `, fma.ID, installer1NoTeam) return err }) @@ -4493,9 +4490,10 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "a.sh", "title-a")) require.NoError(t, err) - // Same hash under a different title on the same team → allowed (dedupe is title-scoped) + // Duplicate on Team A with different name/title but same hash → reject _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "b.sh", "title-b")) - require.NoError(t, err) + var iae *fleet.InvalidArgumentError + require.ErrorAs(t, err, &iae) // Same hash on different team → allowed _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamB.ID, "c.sh", "title-c")) @@ -4505,9 +4503,10 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(nil, "global1.sh", "title-g1")) require.NoError(t, err) - // Global scope, same hash under a different title → allowed + // Global scope second time (duplicate hash) → reject _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(nil, "global2.sh", "title-g2")) - require.NoError(t, err) + var iae2 *fleet.InvalidArgumentError + require.ErrorAs(t, err, &iae2) // Test that binary packages (.pkg) with duplicate hash ARE allowed mkPkgPayload := func(teamID *uint, filename, title string) *fleet.UploadSoftwareInstallerPayload { @@ -4534,7 +4533,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPkgPayload(&teamA.ID, "pkg2.pkg", "title-pkg2")) require.NoError(t, err, "binary packages with same hash should be allowed on same team") - // Same title and hash on the same team → rejected by hash + // Same title and hash on the same team → rejected by the within-title hash check _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "a.sh", "title-a")) require.ErrorContains(t, err, "same SHA-256 hash") } @@ -6064,7 +6063,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data // Same title and version but different content is allowed (e.g. Arm vs Intel builds). _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - StorageID: "arch-arm-storage", + StorageID: "arch-storage-arm", Filename: "arch-app-arm.pkg", Title: "Arch App", BundleIdentifier: "com.example.arch", @@ -6079,7 +6078,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data require.NoError(t, err) _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - StorageID: "arch-intel-storage", + StorageID: "arch-storage-intel", Filename: "arch-app-intel.pkg", Title: "Arch App", BundleIdentifier: "com.example.arch", @@ -6093,8 +6092,8 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data }) require.NoError(t, err) - // A title holds at most maxPackagesPerTitle packages, so the next one is rejected. - for i := range maxPackagesPerTitle { + // A title holds at most fleet.MaxPackagesPerTitle packages, so the next one is rejected. + for i := range fleet.MaxPackagesPerTitle { _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: fmt.Sprintf("limit-storage-%d", i), Filename: fmt.Sprintf("limit-%d.msi", i), @@ -6121,7 +6120,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, fmt.Sprintf("already has %d packages", maxPackagesPerTitle)) + require.ErrorContains(t, err, fmt.Sprintf("already has %d packages", fleet.MaxPackagesPerTitle)) } func testGetSoftwareTitlesForInstallAll(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index c412aab345d..aa295ed20a4 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -685,10 +685,10 @@ WHERE {{end}} {{end}} {{if and (hasTeamID $) $.HashSHA256}} - {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers sif WHERE sif.title_id = st.id AND sif.global_or_team_id = %d AND sif.is_active = TRUE AND sif.storage_id = ?)" $additionalWhere (teamID $)}} + {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers si2 WHERE si2.title_id = st.id AND si2.global_or_team_id = %d AND si2.is_active = TRUE AND si2.storage_id = ?)" $additionalWhere (teamID $)}} {{end}} {{if and (hasTeamID $) $.PackageName}} - {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers sif WHERE sif.title_id = st.id AND sif.global_or_team_id = %d AND sif.is_active = TRUE AND sif.filename = ?)" $additionalWhere (teamID $)}} + {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers si2 WHERE si2.title_id = st.id AND si2.global_or_team_id = %d AND si2.is_active = TRUE AND si2.filename = ?)" $additionalWhere (teamID $)}} {{end}} {{$additionalWhere}} {{end}} diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index 8d3b0112e3e..68cc11dcddc 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -922,21 +922,18 @@ func testListSoftwareTitlesMultiplePackages(t *testing.T, ds *Datastore) { require.NoError(t, err) adminFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}} - countMultiApp := func(opts fleet.SoftwareTitleListOptions) int { - titles, _, _, err := ds.ListSoftwareTitles(ctx, opts, adminFilter) - require.NoError(t, err) - var n int - for _, tl := range titles { - if tl.Name == "Multi App" { - n++ - } - } - return n - } - // the title appears once on the optimized path (no filters) and the filtered path - require.Equal(t, 1, countMultiApp(fleet.SoftwareTitleListOptions{TeamID: &team.ID})) - require.Equal(t, 1, countMultiApp(fleet.SoftwareTitleListOptions{TeamID: &team.ID, ListOptions: fleet.ListOptions{MatchQuery: "Multi App"}})) + // the team has only this title, so it must appear exactly once (not duplicated by the two + // active packages) on both the optimized path (no filters) and the filtered path + titles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, adminFilter) + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, "Multi App", titles[0].Name) + + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID, ListOptions: fleet.ListOptions{MatchQuery: "Multi App"}}, adminFilter) + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, "Multi App", titles[0].Name) // the installer count reflects both packages title, err := ds.SoftwareTitleByID(ctx, titleID, &team.ID, adminFilter) diff --git a/server/fleet/errors.go b/server/fleet/errors.go index c7c87ba7af1..aa5c4d5590c 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -37,11 +37,11 @@ var ( CantEnablePINRequiredIfDiskEncryptionEnabled = "Couldn't enable BitLocker PIN requirement, you must enable disk encryption first." CantResendAppleDeclarationProfilesMessage = "Can't resend declaration (DDM) profiles. Unlike configuration profiles (.mobileconfig), the host automatically checks in to get the latest DDM profiles." CantAddSoftwareConflictMessage = "Couldn't add software. %s already has an installer available for the %s fleet." - SoftwarePackageHashConflictMessage = "Couldn't add. %s package is already added (same SHA-256 hash)." - SoftwareAlreadyHasVPPAppMessage = "Couldn't add. %s already has an Apple App Store (VPP) on the %s fleet." - SoftwareAlreadyHasFleetMaintainedAppMessage = "Couldn't add. %s already has a Fleet-maintained app on the %s fleet." - SoftwareAlreadyHasPackageMessage = "Couldn't add. %s already has a software package on the %s fleet." - SoftwarePackageLimitMessage = "Couldn't add. %s already has %d packages. Before adding, delete one you no longer use." + SoftwarePackageHashConflictMessage = "%s package is already added (same SHA-256 hash)." + SoftwareAlreadyHasVPPAppMessage = "%s already has an Apple App Store (VPP) on the %s fleet." + SoftwareAlreadyHasFleetMaintainedAppMessage = "%s already has a Fleet-maintained app on the %s fleet." + SoftwareAlreadyHasPackageMessage = "%s already has a software package on the %s fleet." + SoftwarePackageLimitMessage = "%s already has %d packages. Before adding, delete one you no longer use." ConfigProfileLabelScopingPremiumCauseMsg = "Scoping configuration profiles with labels" ) diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index d1827410059..a8926be5b4c 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -1195,6 +1195,9 @@ type SoftwareScopeLabel struct { // Max total attempts (including initial) for a non-policy software install. const MaxSoftwareInstallAttempts = 3 +// MaxPackagesPerTitle caps how many custom packages a single software title can hold per team. +const MaxPackagesPerTitle = 10 + // HostSoftwareInstallOptions contains options that apply to a software or VPP // app install request. type HostSoftwareInstallOptions struct { From 0c628162f19ef6b1958d99149338447b151c0957 Mon Sep 17 00:00:00 2001 From: Carlo DiCelico Date: Wed, 1 Jul 2026 19:56:13 -0400 Subject: [PATCH 7/9] first pass at api changes --- ee/server/service/software_installers.go | 92 +++++++++- server/datastore/mysql/software.go | 41 +++++ server/datastore/mysql/software_installers.go | 10 +- server/datastore/mysql/software_titles.go | 86 ++++++++++ server/fleet/datastore.go | 15 +- server/fleet/service.go | 2 +- server/fleet/software.go | 15 +- server/mock/datastore_mock.go | 24 +++ server/mock/service/service_mock.go | 6 +- server/service/integration_enterprise_test.go | 157 ++++++++++++++++++ server/service/software_installers.go | 26 ++- server/service/software_installers_test.go | 5 +- server/service/software_titles.go | 84 +++++++--- server/service/testing_client_test.go | 3 + 14 files changed, 524 insertions(+), 42 deletions(-) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 5bc895fa443..a227a3a0a06 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -212,6 +212,11 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return addedInstaller, nil } + // NOTE(#48397): with multiple packages per title this returns the first-added + // package's metadata, not necessarily the one just uploaded. Returning the + // just-added package would need a full-hydration read by installer id (the + // existing by-id read is intentionally light for the osquery hot path), so the + // authoritative multi-package view is GET /software/titles/:id (packages[]). addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting added software installer") @@ -411,19 +416,51 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return svc.updateInHouseAppInstaller(ctx, payload, vc, teamName, software) } - // With more than one installer on the title, this edits the first-added one. - // Choosing a specific package to edit is handled by the precedence work. if software.SoftwareInstallersCount < 1 { return nil, &fleet.BadRequestError{ Message: "There are no software installers defined yet for this title and team. Please add an installer instead of attempting to edit.", } } + // existingInstaller defaults to the first-added package (fully hydrated, incl. + // the title-level icon used in the activity). existingInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting existing installer") } + // With more than one package on a title, the edit must target a specific + // package. siblings is loaded once and reused for the hash-collision check. + var siblings []*fleet.SoftwareInstaller + if software.SoftwareInstallersCount > 1 || payload.InstallerID != 0 { + siblings, err = svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting title packages") + } + + switch { + case payload.InstallerID == 0 && software.SoftwareInstallersCount > 1: + return nil, &fleet.BadRequestError{ + Message: "installer_id is required when the title has multiple packages.", + } + case payload.InstallerID != 0 && payload.InstallerID != existingInstaller.InstallerID: + var target *fleet.SoftwareInstaller + for _, p := range siblings { + if p.InstallerID == payload.InstallerID { + target = p + break + } + } + if target == nil { + return nil, ctxerr.Wrapf(ctx, ¬FoundError{}, + "installer %d does not belong to this title and team", payload.InstallerID) + } + // The icon is title-level, so carry it from the first-added read for the activity. + target.IconUrl = existingInstaller.IconUrl + existingInstaller = target + } + } + if payload.IsNoopPayload(software) { return existingInstaller, nil // no payload, noop } @@ -500,6 +537,17 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID { + // Reject a replacement whose hash matches a different package on the same + // title. The per-title dedup_token key would otherwise raise a raw 1062. + // (siblings is empty for single-package titles, so this is a no-op there.) + for _, p := range siblings { + if p.InstallerID != existingInstaller.InstallerID && p.StorageID == payloadForNewInstallerFile.StorageID { + return nil, ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageHashConflictMessage, payloadForNewInstallerFile.Filename), + }, "edit collides with sibling package hash") + } + } + activity.SoftwarePackage = &payload.Filename payload.StorageID = payloadForNewInstallerFile.StorageID payload.Filename = payloadForNewInstallerFile.Filename @@ -917,7 +965,7 @@ func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, exi return false, nil, nil } -func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error { if teamID == nil { return fleet.NewInvalidArgumentError("fleet_id", "is required") } @@ -928,7 +976,8 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return err } - // first, look for a software installer + // Resolve the title's type. metaInstaller is fully hydrated (incl. the + // title-level icon), which the per-package reads below don't carry. metaInstaller, errInstaller := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) metaVPP, errVPP := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) metaInHouse, errInHouse := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID) @@ -942,9 +991,42 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return ctxerr.Wrap(ctx, errInHouse, "getting in house app metadata") } + // Delete a single package by installer id (an installer id always refers to a + // software installer, never a VPP or in-house app). + if installerID != nil { + if metaInstaller == nil { + return ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", *installerID) + } + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting title packages") + } + for _, pkg := range pkgs { + if pkg.InstallerID == *installerID { + pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity + return svc.deleteSoftwareInstaller(ctx, pkg) + } + } + return ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", *installerID) + } + switch { case metaInstaller != nil: - return svc.deleteSoftwareInstaller(ctx, metaInstaller) + // Custom-package title: delete every package (title-level delete). FMA titles + // keep a single active row, so this matches the prior single-delete behavior + // for them. Deletes are per-package, so a package blocked by a setup-experience + // or patch-policy guard fails the title delete partway. + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting title packages to delete") + } + for _, pkg := range pkgs { + pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity + if err := svc.deleteSoftwareInstaller(ctx, pkg); err != nil { + return err + } + } + return nil case metaVPP != nil: return svc.deleteVPPApp(ctx, teamID, metaVPP) case metaInHouse != nil: diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 76cccd05935..dc75c417c66 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -7165,3 +7165,44 @@ WHERE return ret, nil } + +// GetCategoriesForSoftwareInstallers returns the category names for each of the +// given software installers, keyed by installer id. Categories are per-package +// (unlike GetCategoriesForSoftwareTitles, which merges them across a title's +// packages), so this is used to populate per-package categories in the +// multi-package API responses. +func (ds *Datastore) GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) { + if len(installerIDs) == 0 { + return map[uint][]string{}, nil + } + + stmt := ` +SELECT + sisc.software_installer_id AS installer_id, + sc.name AS software_category_name +FROM + software_installer_software_categories sisc + JOIN software_categories sc ON sc.id = sisc.software_category_id +WHERE + sisc.software_installer_id IN (?) +ORDER BY sc.name` + + stmt, args, err := sqlx.In(stmt, installerIDs) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "sqlx.In for get categories for software installers by id") + } + var categories []struct { + InstallerID uint `db:"installer_id"` + CategoryName string `db:"software_category_name"` + } + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &categories, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get categories for software installers by id") + } + + ret := make(map[uint][]string, len(categories)) + for _, c := range categories { + ret[c.InstallerID] = append(ret[c.InstallerID], c.CategoryName) + } + + return ret, nil +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 29f3d50b242..ca6a8089cf3 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -1359,6 +1359,8 @@ LIMIT 1`, } func (ds *Datastore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + // Script contents are joined in so the API detail shape (and the edit path, + // which reads the existing install script) has the full package. const query = ` SELECT si.id, @@ -1381,10 +1383,16 @@ SELECT si.url, COALESCE(st.name, '') AS software_title, COALESCE(st.bundle_identifier, '') AS bundle_identifier, - si.patch_query + si.patch_query, + inst.contents AS install_script, + COALESCE(pinst.contents, '') AS post_install_script, + uninst.contents AS uninstall_script FROM software_installers si JOIN software_titles st ON st.id = si.title_id + LEFT OUTER JOIN script_contents inst ON inst.id = si.install_script_content_id + LEFT OUTER JOIN script_contents pinst ON pinst.id = si.post_install_script_content_id + LEFT OUTER JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id WHERE si.title_id = ? AND si.global_or_team_id = ? AND si.is_active = 1 diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index aa295ed20a4..ea87a333762 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -483,6 +483,30 @@ func (ds *Datastore) processSoftwareTitleResults( softwareList[i].DisplayName = displayName } } + + // Populate the multi-package packages[] array (active custom packages only). + // The main query returns a single installer row per title, so the full set + // is fetched separately here. + packagesByTitle, err := ds.GetSoftwarePackagesForTitles(ctx, opt.TeamID, titleIDs) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "get packages for software titles") + } + // v1: automatic install policies are title-level, so attach the same set to + // every package. #48398 will attribute policies per-installer. + policiesByTitle := make(map[uint][]fleet.AutomaticInstallPolicy, len(policies)) + for _, p := range policies { + policiesByTitle[p.TitleID] = append(policiesByTitle[p.TitleID], p) + } + for titleID, pkgs := range packagesByTitle { + i, ok := titleIndex[titleID] + if !ok { + continue + } + for j := range pkgs { + pkgs[j].AutomaticInstallPolicies = policiesByTitle[titleID] + } + softwareList[i].Packages = pkgs + } } // Fetch matching versions separately to avoid aggregating nested arrays in the main query. @@ -557,6 +581,68 @@ func (ds *Datastore) processSoftwareTitleResults( return titles, counts, metaData, nil } +// GetSoftwarePackagesForTitles returns the trimmed per-package information for +// every active custom package on the given titles, keyed by title id and +// ordered first-added first. It is used to populate the packages[] array on the +// list-titles response; the full per-package shape lives on the detail endpoint. +func (ds *Datastore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageOrApp, error) { + if len(titleIDs) == 0 { + return map[uint][]fleet.SoftwarePackageOrApp{}, nil + } + + const stmt = ` +SELECT + si.title_id, + si.filename AS name, + si.version, + si.platform, + si.self_service, + si.url AS package_url +FROM + software_installers si +WHERE + si.global_or_team_id = ? AND si.is_active = 1 AND si.title_id IN (?) +ORDER BY si.id ASC` + + type packageRow struct { + TitleID uint `db:"title_id"` + Name string `db:"name"` + Version string `db:"version"` + Platform string `db:"platform"` + SelfService bool `db:"self_service"` + PackageURL *string `db:"package_url"` + } + + ret := make(map[uint][]fleet.SoftwarePackageOrApp) + batchSize := 32000 + err := common_mysql.BatchProcessSimple(titleIDs, batchSize, func(titleIDsToProcess []uint) error { + query, args, err := sqlx.In(stmt, ptr.ValOrZero(teamID), titleIDsToProcess) + if err != nil { + return ctxerr.Wrap(ctx, err, "sqlx.In for get packages for titles") + } + var rows []packageRow + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, query, args...); err != nil { + return ctxerr.Wrap(ctx, err, "get packages for titles") + } + for _, r := range rows { + selfService := r.SelfService + ret[r.TitleID] = append(ret[r.TitleID], fleet.SoftwarePackageOrApp{ + Name: r.Name, + Version: r.Version, + Platform: r.Platform, + SelfService: &selfService, + PackageURL: r.PackageURL, + }) + } + return nil + }) + if err != nil { + return nil, err + } + + return ret, nil +} + // spliceSecondaryOrderBySoftwareTitlesSQL adds a secondary order by clause, splicing it into the // existing order by clause. This is necessary because multicolumn sort is not // supported by appendListOptionsWithCursorToSQL. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 654472ec6d2..33e7c971e5b 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -845,6 +845,12 @@ type Datastore interface { // from the title IDs to the categories assigned to the installers for those titles. GetCategoriesForSoftwareTitles(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) + // GetCategoriesForSoftwareInstallers takes a set of software installer IDs and + // returns a map from installer ID to the categories assigned to that installer. + // Unlike GetCategoriesForSoftwareTitles, categories are not merged across a + // title's packages, so this supports per-package categories. + GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) + // GetSoftwareTitlesForInstallAll returns the self-service software titles available // to queue for the host's "install all" action, in alphabetical order, optionally // scoped to a category. @@ -2690,9 +2696,16 @@ type Datastore interface { GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) // GetSoftwarePackagesByTeamAndTitleID returns every active package for the given - // title and team, ordered first-added first, each with its label scope. + // title and team, ordered first-added first, each with its label scope and + // script contents. GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*SoftwareInstaller, error) + // GetSoftwarePackagesForTitles returns the trimmed per-package information for + // every active custom package on the given titles, keyed by title id and + // ordered first-added first. It backs the packages[] array on the list-titles + // response. + GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]SoftwarePackageOrApp, error) + // GetFleetMaintainedVersionsByTitleID returns all cached versions of a // fleet-maintained app for the given title and team. If byVersion is true // the versions will be sorted by their version semver or string. diff --git a/server/fleet/service.go b/server/fleet/service.go index 607767b1ce1..429ceafa652 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1391,7 +1391,7 @@ type Service interface { UploadSoftwareInstaller(ctx context.Context, payload *UploadSoftwareInstallerPayload) (*SoftwareInstaller, error) UpdateSoftwareInstaller(ctx context.Context, payload *UpdateSoftwareInstallerPayload) (*SoftwareInstaller, error) - DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error + DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) GetSoftwareInstallerTokenMetadata(ctx context.Context, token string, titleID uint) (*SoftwareInstallerTokenMetadata, error) GetSoftwareInstallerMetadata(ctx context.Context, skipAuthz bool, titleID uint, teamID *uint) (*SoftwareInstaller, error) diff --git a/server/fleet/software.go b/server/fleet/software.go index c42c9cf4993..f12dcc54608 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -439,8 +439,14 @@ type SoftwareTitle struct { // InHouseAppsCount is 0 or 1, indicating if the software title has // an in house app (.ipa) installer InHouseAppCount int `json:"-" db:"in_house_apps_count"` - // SoftwarePackage is the software installer information for this title. + // SoftwarePackage is the software installer information for this title. It is + // retained for backwards compatibility and holds the first-added (oldest) + // package; it is nil when the title has no packages. SoftwarePackage *SoftwareInstaller `json:"software_package" db:"-"` + // Packages holds every custom package added for this title (ordered + // first-added first). It is nil when the title has no packages, so it + // marshals to null. + Packages []SoftwareInstaller `json:"packages" db:"-"` // AppStoreApp is the VPP app information for this title. AppStoreApp *VPPAppStoreApp `json:"app_store_app" db:"-"` // BundleIdentifier is used by Apple installers to uniquely identify @@ -526,8 +532,15 @@ type SoftwareTitleListResult struct { // SoftwarePackage provides software installer package information, it is // only present if a software installer is available for the software title. + // It is retained for backwards compatibility and holds the first-added + // (oldest) package. SoftwarePackage *SoftwarePackageOrApp `json:"software_package"` + // Packages provides the trimmed package information for every custom package + // added for this title (ordered first-added first). It is nil (marshals to + // null) when the title has no packages. + Packages []SoftwarePackageOrApp `json:"packages"` + // AppStoreApp provides VPP app information, it is only present if a VPP app // is available for the software title. AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"` diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 3fb5d4e48ef..4aa0b06d825 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -612,6 +612,8 @@ type GetSoftwareCategoryNameToIDMapFunc func(ctx context.Context, teamID uint, n type GetCategoriesForSoftwareTitlesFunc func(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) +type GetCategoriesForSoftwareInstallersFunc func(ctx context.Context, installerIDs []uint) (map[uint][]string, error) + type GetSoftwareTitlesForInstallAllFunc func(ctx context.Context, host *fleet.Host, categoryID *uint) ([]*fleet.HostSoftwareWithInstaller, *string, error) type AssociateMDMInstallToVerificationUUIDFunc func(ctx context.Context, installUUID string, verifyCommandUUID string, hostUUID string) error @@ -1578,6 +1580,8 @@ type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, type GetSoftwarePackagesByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) +type GetSoftwarePackagesForTitlesFunc func(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageOrApp, error) + type GetFleetMaintainedVersionsByTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) type ListFleetMaintainedAppActiveInstallersFunc func(ctx context.Context) ([]fleet.FMAAutoUpdateCandidate, error) @@ -3023,6 +3027,9 @@ type DataStore struct { GetCategoriesForSoftwareTitlesFunc GetCategoriesForSoftwareTitlesFunc GetCategoriesForSoftwareTitlesFuncInvoked bool + GetCategoriesForSoftwareInstallersFunc GetCategoriesForSoftwareInstallersFunc + GetCategoriesForSoftwareInstallersFuncInvoked bool + GetSoftwareTitlesForInstallAllFunc GetSoftwareTitlesForInstallAllFunc GetSoftwareTitlesForInstallAllFuncInvoked bool @@ -4472,6 +4479,9 @@ type DataStore struct { GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFuncInvoked bool + GetSoftwarePackagesForTitlesFunc GetSoftwarePackagesForTitlesFunc + GetSoftwarePackagesForTitlesFuncInvoked bool + GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFuncInvoked bool @@ -7376,6 +7386,13 @@ func (s *DataStore) GetCategoriesForSoftwareTitles(ctx context.Context, software return s.GetCategoriesForSoftwareTitlesFunc(ctx, softwareTitleIDs, team_id) } +func (s *DataStore) GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) { + s.mu.Lock() + s.GetCategoriesForSoftwareInstallersFuncInvoked = true + s.mu.Unlock() + return s.GetCategoriesForSoftwareInstallersFunc(ctx, installerIDs) +} + func (s *DataStore) GetSoftwareTitlesForInstallAll(ctx context.Context, host *fleet.Host, categoryID *uint) ([]*fleet.HostSoftwareWithInstaller, *string, error) { s.mu.Lock() s.GetSoftwareTitlesForInstallAllFuncInvoked = true @@ -10757,6 +10774,13 @@ func (s *DataStore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, tea return s.GetSoftwarePackagesByTeamAndTitleIDFunc(ctx, teamID, titleID) } +func (s *DataStore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageOrApp, error) { + s.mu.Lock() + s.GetSoftwarePackagesForTitlesFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwarePackagesForTitlesFunc(ctx, teamID, titleIDs) +} + func (s *DataStore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) { s.mu.Lock() s.GetFleetMaintainedVersionsByTitleIDFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 9944ff3d3aa..79e7117f30a 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -838,7 +838,7 @@ type UploadSoftwareInstallerFunc func(ctx context.Context, payload *fleet.Upload type UpdateSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) -type DeleteSoftwareInstallerFunc func(ctx context.Context, titleID uint, teamID *uint) error +type DeleteSoftwareInstallerFunc func(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error type GenerateSoftwareInstallerTokenFunc func(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) @@ -5197,11 +5197,11 @@ func (s *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.Up return s.UpdateSoftwareInstallerFunc(ctx, payload) } -func (s *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (s *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error { s.mu.Lock() s.DeleteSoftwareInstallerFuncInvoked = true s.mu.Unlock() - return s.DeleteSoftwareInstallerFunc(ctx, titleID, teamID) + return s.DeleteSoftwareInstallerFunc(ctx, titleID, teamID, installerID) } func (s *Service) GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 1ef04fbc34b..315c0ba8514 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -17120,6 +17120,163 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() { require.Empty(t, storedURL, "cache-hit re-apply must drop the placeholder url too") } +func (s *integrationEnterpriseTestSuite) TestSoftwareMultiplePackagesPerTitle() { + t := s.T() + ctx := context.Background() + + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + + // Two script packages with the same filename resolve to the same title + // ("deploy"), and different contents give them distinct content hashes, so + // they coexist as two packages under one title. + contentA := "#!/bin/bash\necho 'A'\n" + contentB := "#!/bin/bash\necho 'B'\n" + contentC := "#!/bin/bash\necho 'C'\n" + hashOf := func(s string) string { sum := sha256.Sum256([]byte(s)); return hex.EncodeToString(sum[:]) } + + upload := func(content string, selfService bool, expectedStatus int, expectedErr string) { + fr, err := fleet.NewTempFileReader(strings.NewReader(content), func() string { return t.TempDir() }) + require.NoError(t, err) + defer fr.Close() + s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{ + Filename: "deploy.sh", + TeamID: &team.ID, + SelfService: selfService, + InstallerFile: fr, + }, expectedStatus, expectedErr) + } + + // Add a first package (A, not self-service), then a second (B, self-service). + upload(contentA, false, http.StatusOK, "") + upload(contentB, true, http.StatusOK, "") + + // Adding a second package emits the same added_software activity. + s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), ``, 0) + + // Re-uploading identical bytes is rejected (per-title hash dedupe). + upload(contentA, false, http.StatusConflict, "already added (same SHA-256 hash)") + + var titleID uint + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &titleID, + `SELECT DISTINCT title_id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, team.ID, "deploy.sh") + }) + require.NotZero(t, titleID) + + getTitle := func() *fleet.SoftwareTitle { + var resp getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), getSoftwareTitleRequest{}, + http.StatusOK, &resp, "team_id", fmt.Sprintf("%d", team.ID)) + return resp.SoftwareTitle + } + + // --- Detail endpoint: full packages[] shape, software_package == first-added --- + title := getTitle() + require.Len(t, title.Packages, 2) + require.NotNil(t, title.SoftwarePackage) + require.Equal(t, title.Packages[0].InstallerID, title.SoftwarePackage.InstallerID) + require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) + require.Equal(t, hashOf(contentB), title.Packages[1].StorageID) + require.Equal(t, hashOf(contentA), title.SoftwarePackage.StorageID) + // per-package fields are independent + require.False(t, title.Packages[0].SelfService) + require.True(t, title.Packages[1].SelfService) + // scripts are hydrated (for a script package the install script is the file) + require.Equal(t, contentA, title.Packages[0].InstallScript) + require.Equal(t, contentB, title.Packages[1].InstallScript) + + installerA := title.Packages[0].InstallerID + installerB := title.Packages[1].InstallerID + + // --- List endpoint: trimmed packages[] --- + var listResp listSoftwareTitlesResponse + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &listResp, + "query", "deploy", "team_id", fmt.Sprintf("%d", team.ID)) + require.Len(t, listResp.SoftwareTitles, 1) + lt := listResp.SoftwareTitles[0] + require.Len(t, lt.Packages, 2) + require.NotNil(t, lt.SoftwarePackage) + require.Equal(t, "deploy.sh", lt.SoftwarePackage.Name) + require.Equal(t, "deploy.sh", lt.Packages[0].Name) + require.NotNil(t, lt.Packages[0].SelfService) + require.False(t, *lt.Packages[0].SelfService) + require.NotNil(t, lt.Packages[1].SelfService) + require.True(t, *lt.Packages[1].SelfService) + + // --- Edit without installer_id on a multi-package title -> 400 --- + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &team.ID, + SelfService: new(true), + }, http.StatusBadRequest, "installer_id is required") + + // --- Edit B's file to A's content (sibling hash collision) -> 409, both unchanged --- + collideFile, err := fleet.NewTempFileReader(strings.NewReader(contentA), func() string { return t.TempDir() }) + require.NoError(t, err) + defer collideFile.Close() + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + InstallerID: installerB, + TeamID: &team.ID, + Filename: "deploy.sh", + InstallerFile: collideFile, + }, http.StatusConflict, "already added (same SHA-256 hash)") + + title = getTitle() + require.Len(t, title.Packages, 2) + require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) + require.Equal(t, hashOf(contentB), title.Packages[1].StorageID) + + // --- Edit B's file to a new hash -> ok; A untouched --- + newFile, err := fleet.NewTempFileReader(strings.NewReader(contentC), func() string { return t.TempDir() }) + require.NoError(t, err) + defer newFile.Close() + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + InstallerID: installerB, + TeamID: &team.ID, + Filename: "deploy.sh", + InstallerFile: newFile, + }, http.StatusOK, "") + title = getTitle() + require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) + require.Equal(t, hashOf(contentC), title.Packages[1].StorageID) + + // --- Re-save B's current file -> no-op (200) --- + sameFile, err := fleet.NewTempFileReader(strings.NewReader(contentC), func() string { return t.TempDir() }) + require.NoError(t, err) + defer sameFile.Close() + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + InstallerID: installerB, + TeamID: &team.ID, + Filename: "deploy.sh", + InstallerFile: sameFile, + }, http.StatusOK, "") + title = getTitle() + require.Len(t, title.Packages, 2) + require.Equal(t, hashOf(contentC), title.Packages[1].StorageID) + + // --- Delete one package (A) -> 204, B remains and becomes first-added --- + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, + http.StatusNoContent, "team_id", fmt.Sprintf("%d", team.ID), "installer_id", fmt.Sprintf("%d", installerA)) + title = getTitle() + require.Len(t, title.Packages, 1) + require.Equal(t, installerB, title.Packages[0].InstallerID) + require.Equal(t, installerB, title.SoftwarePackage.InstallerID) + + // --- Delete all remaining (no installer_id) -> 204, no packages left --- + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, + http.StatusNoContent, "team_id", fmt.Sprintf("%d", team.ID)) + var remaining int + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &remaining, + `SELECT COUNT(*) FROM software_installers WHERE global_or_team_id = ? AND title_id = ?`, team.ID, titleID) + }) + require.Zero(t, remaining, "title-level delete removes all packages") +} + // 1. host reports software // 2. reconciler runs, creates title // 3. installer is uploaded, creates a new software title diff --git a/server/service/software_installers.go b/server/service/software_installers.go index ed85e4ffa79..494b065c7aa 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -42,7 +42,10 @@ type uploadSoftwareInstallerRequest struct { } type updateSoftwareInstallerRequest struct { - TitleID uint `url:"id"` + TitleID uint `url:"id"` + // InstallerID identifies which package to edit when the title has more than + // one; it is required in that case and optional for single-package titles. + InstallerID *uint File *multipart.FileHeader TeamID *uint InstallScript *string @@ -122,6 +125,15 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.TeamID = ptr.Uint(uint(fleetID)) } + // installer_id targets a specific package when the title has multiple. + if idVal, ok := r.MultipartForm.Value["installer_id"]; ok && len(idVal) > 0 && idVal[0] != "" { + installerID, err := strconv.ParseUint(idVal[0], 10, 32) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid installer_id: %s", idVal[0])} + } + decoded.InstallerID = new(uint(installerID)) + } + installScriptMultipart, ok := r.MultipartForm.Value["install_script"] if ok && len(installScriptMultipart) > 0 { decoded.InstallScript = &installScriptMultipart[0] @@ -254,6 +266,7 @@ func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s payload := &fleet.UpdateSoftwareInstallerPayload{ TitleID: req.TitleID, + InstallerID: ptr.ValOrZero(req.InstallerID), TeamID: req.TeamID, InstallScript: req.InstallScript, PreInstallQuery: req.PreInstallQuery, @@ -500,8 +513,11 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. } type deleteSoftwareInstallerRequest struct { - TeamID *uint `query:"team_id" renameto:"fleet_id"` - TitleID uint `url:"title_id"` + TeamID *uint `query:"team_id" renameto:"fleet_id"` + // InstallerID deletes a single package; when omitted, all of the title's + // custom packages are deleted (title-level delete). + InstallerID *uint `query:"installer_id,optional"` + TitleID uint `url:"title_id"` } type deleteSoftwareInstallerResponse struct { @@ -513,14 +529,14 @@ func (r deleteSoftwareInstallerResponse) Status() int { return http.StatusNoCon func deleteSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*deleteSoftwareInstallerRequest) - err := svc.DeleteSoftwareInstaller(ctx, req.TitleID, req.TeamID) + err := svc.DeleteSoftwareInstaller(ctx, req.TitleID, req.TeamID, req.InstallerID) if err != nil { return deleteSoftwareInstallerResponse{Err: err}, nil } return deleteSoftwareInstallerResponse{}, nil } -func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *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_test.go b/server/service/software_installers_test.go index 26a87d5374b..5e6a3f887e4 100644 --- a/server/service/software_installers_test.go +++ b/server/service/software_installers_test.go @@ -98,6 +98,9 @@ func TestSoftwareInstallersAuth(t *testing.T) { ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{TeamID: tt.teamID}, nil } + ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + return []*fleet.SoftwareInstaller{{TeamID: tt.teamID, InstallerID: 1}}, nil + } ds.DeleteSoftwareInstallerFunc = func(ctx context.Context, installerID uint) error { return nil @@ -145,7 +148,7 @@ func TestSoftwareInstallersAuth(t *testing.T) { checkAuthErr(t, tt.shouldFailRead, err) } - err = svc.DeleteSoftwareInstaller(ctx, 1, tt.teamID) + err = svc.DeleteSoftwareInstaller(ctx, 1, tt.teamID, nil) if tt.teamID == nil { require.Error(t, err) } else { diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 4b5cbafebc6..8becf5b053c 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -192,40 +192,76 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint if license.IsPremium() { // add software installer data if needed if software.SoftwareInstallersCount > 0 { - meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true) + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, id) if err != nil && !fleet.IsNotFound(err) { - return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") + return nil, ctxerr.Wrap(ctx, err, "get software packages") } - if meta != nil { - summary, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, meta.InstallerID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get software installer status summary") + if len(pkgs) > 0 { + // Display name, icon, and automatic install policies are title-level + // (identical across a title's packages); fetch them once from the + // first-added package's full metadata. + titleMeta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") } - meta.Status = summary - } - software.SoftwarePackage = meta - // Populate FleetMaintainedVersions if this is an FMA - if meta != nil && meta.FleetMaintainedAppID != nil { - fmaVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, id, false) + // Categories are per-package. + installerIDs := make([]uint, len(pkgs)) + for i, pkg := range pkgs { + installerIDs[i] = pkg.InstallerID + } + categoriesByInstaller, err := svc.ds.GetCategoriesForSoftwareInstallers(ctx, installerIDs) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions") + return nil, ctxerr.Wrap(ctx, err, "get categories for software packages") } - meta.FleetMaintainedVersions = fmaVersions - // No pin row means the title tracks "Latest" (nil pinned_version); any other error is real. - pinnedVersion, err := svc.ds.GetPinnedVersion(ctx, teamID, id) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, ctxerr.Wrap(ctx, err, "get pinned version") + for _, pkg := range pkgs { + summary, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, pkg.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installer status summary") + } + pkg.Status = summary + pkg.Categories = categoriesByInstaller[pkg.InstallerID] + + if titleMeta != nil { + pkg.DisplayName = titleMeta.DisplayName + pkg.IconUrl = titleMeta.IconUrl + // v1: automatic install policies are title-level. #48398 will + // attribute them per-installer. + pkg.AutomaticInstallPolicies = titleMeta.AutomaticInstallPolicies + } + + // Populate FleetMaintainedVersions/pin/patch policy for FMA titles. + // An FMA title has a single active package, so this runs on it. + if pkg.FleetMaintainedAppID != nil { + fmaVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, id, false) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions") + } + pkg.FleetMaintainedVersions = fmaVersions + + // No pin row means the title tracks "Latest" (nil pinned_version); any other error is real. + pinnedVersion, err := svc.ds.GetPinnedVersion(ctx, teamID, id) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, err, "get pinned version") + } + pkg.PinnedVersion = pinnedVersion + + patchPolicy, err := svc.ds.GetPatchPolicy(ctx, teamID, id) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get patch policy") + } + pkg.PatchPolicy = patchPolicy + } } - meta.PinnedVersion = pinnedVersion - // Populate PatchPolicy if there is one - patchPolicy, err := svc.ds.GetPatchPolicy(ctx, teamID, id) - if err != nil && !fleet.IsNotFound(err) { - return nil, ctxerr.Wrap(ctx, err, "get patch policy") + // packages[] holds every package; software_package is retained for + // backwards compatibility and equals the first-added package. + software.Packages = make([]fleet.SoftwareInstaller, len(pkgs)) + for i, pkg := range pkgs { + software.Packages[i] = *pkg } - meta.PatchPolicy = patchPolicy + software.SoftwarePackage = pkgs[0] } } diff --git a/server/service/testing_client_test.go b/server/service/testing_client_test.go index 5f1770b09e2..20413129013 100644 --- a/server/service/testing_client_test.go +++ b/server/service/testing_client_test.go @@ -956,6 +956,9 @@ func (ts *withServer) updateSoftwareInstaller( tmID = *payload.TeamID } require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", tmID))) + if payload.InstallerID != 0 { + require.NoError(t, w.WriteField("installer_id", fmt.Sprintf("%d", payload.InstallerID))) + } // add the remaining fields if payload.InstallScript != nil { require.NoError(t, w.WriteField("install_script", *payload.InstallScript)) From 8b7c38b1e36b9bd854d25e8bac526e24d4bc5859 Mon Sep 17 00:00:00 2001 From: Carlo DiCelico Date: Thu, 2 Jul 2026 15:22:00 -0400 Subject: [PATCH 8/9] conform add/edit/list responses to REST spec --- ee/server/service/software_installers.go | 38 ++++----- server/datastore/mysql/software.go | 7 +- server/datastore/mysql/software_installers.go | 44 +++++++---- server/datastore/mysql/software_titles.go | 21 ++--- server/fleet/datastore.go | 19 ++--- server/fleet/software.go | 19 ++--- server/fleet/software_installer.go | 11 +++ server/mock/datastore_mock.go | 16 +++- server/service/integration_enterprise_test.go | 78 ++++++++++++++++--- server/service/software_installers.go | 7 +- server/service/software_titles.go | 10 +-- 11 files changed, 166 insertions(+), 104 deletions(-) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index a227a3a0a06..d52998b04f0 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -212,12 +212,8 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return addedInstaller, nil } - // NOTE(#48397): with multiple packages per title this returns the first-added - // package's metadata, not necessarily the one just uploaded. Returning the - // just-added package would need a full-hydration read by installer id (the - // existing by-id read is intentionally light for the osquery hot path), so the - // authoritative multi-package view is GET /software/titles/:id (packages[]). - addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, true) + // Return the package just added, not the title's first-added one. + addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, installerID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting added software installer") } @@ -422,15 +418,13 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } } - // existingInstaller defaults to the first-added package (fully hydrated, incl. - // the title-level icon used in the activity). + // Defaults to the first-added package; a specific installer_id overrides it below. existingInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting existing installer") } - // With more than one package on a title, the edit must target a specific - // package. siblings is loaded once and reused for the hash-collision check. + // siblings is reused for both installer targeting and the hash-collision check below. var siblings []*fleet.SoftwareInstaller if software.SoftwareInstallersCount > 1 || payload.InstallerID != 0 { siblings, err = svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID) @@ -455,7 +449,7 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return nil, ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", payload.InstallerID) } - // The icon is title-level, so carry it from the first-added read for the activity. + // Icon is title-level; carry it from the first-added read for the activity. target.IconUrl = existingInstaller.IconUrl existingInstaller = target } @@ -537,9 +531,7 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID { - // Reject a replacement whose hash matches a different package on the same - // title. The per-title dedup_token key would otherwise raise a raw 1062. - // (siblings is empty for single-package titles, so this is a no-op there.) + // Catch a sibling hash match for a friendly 409; the dedup_token key would otherwise raise a raw 1062. for _, p := range siblings { if p.InstallerID != existingInstaller.InstallerID && p.StorageID == payloadForNewInstallerFile.StorageID { return nil, ctxerr.Wrap(ctx, fleet.ConflictError{ @@ -873,8 +865,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } } - // re-pull installer from database to ensure any side effects are accounted for; may be able to optimize this out later - updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, true) + // re-pull the edited installer to reflect side effects; return that specific + // package, not the title's first-added one. May be able to optimize this out later. + updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, payload.InstallerID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "re-hydrating updated installer metadata") } @@ -976,8 +969,7 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return err } - // Resolve the title's type. metaInstaller is fully hydrated (incl. the - // title-level icon), which the per-package reads below don't carry. + // metaInstaller is fully hydrated (incl. the title-level icon) which the per-package reads below lack. metaInstaller, errInstaller := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) metaVPP, errVPP := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) metaInHouse, errInHouse := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID) @@ -991,8 +983,7 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return ctxerr.Wrap(ctx, errInHouse, "getting in house app metadata") } - // Delete a single package by installer id (an installer id always refers to a - // software installer, never a VPP or in-house app). + // An installer id always refers to a software installer, never a VPP or in-house app. if installerID != nil { if metaInstaller == nil { return ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", *installerID) @@ -1012,10 +1003,9 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t switch { case metaInstaller != nil: - // Custom-package title: delete every package (title-level delete). FMA titles - // keep a single active row, so this matches the prior single-delete behavior - // for them. Deletes are per-package, so a package blocked by a setup-experience - // or patch-policy guard fails the title delete partway. + // Delete every package on the title. FMA titles keep one active row, so this + // matches prior behavior for them. Per-package deletes mean a guarded package + // (setup experience / patch policy) fails the title delete partway. pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID) if err != nil { return ctxerr.Wrap(ctx, err, "getting title packages to delete") diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index dc75c417c66..ac6f49782f2 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -7166,11 +7166,8 @@ WHERE return ret, nil } -// GetCategoriesForSoftwareInstallers returns the category names for each of the -// given software installers, keyed by installer id. Categories are per-package -// (unlike GetCategoriesForSoftwareTitles, which merges them across a title's -// packages), so this is used to populate per-package categories in the -// multi-package API responses. +// GetCategoriesForSoftwareInstallers returns categories keyed by installer id, +// unmerged (unlike GetCategoriesForSoftwareTitles) so packages keep their own. func (ds *Datastore) GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) { if len(installerIDs) == 0 { return map[uint][]string{}, nil diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index ca6a8089cf3..9ea993fd6e6 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -1258,6 +1258,17 @@ WHERE } func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + return ds.getSoftwareInstallerMetadata(ctx, teamID, titleID, nil, withScriptContents) +} + +// GetSoftwareInstallerMetadataByTeamTitleAndInstallerID returns the fully-hydrated +// metadata for a specific installer (rather than the first-added one), so add/edit +// responses can echo the affected package. +func (ds *Datastore) GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + return ds.getSoftwareInstallerMetadata(ctx, teamID, titleID, &installerID, withScriptContents) +} + +func (ds *Datastore) getSoftwareInstallerMetadata(ctx context.Context, teamID *uint, titleID uint, installerID *uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { var scriptContentsSelect, scriptContentsFrom string if withScriptContents { scriptContentsSelect = ` , inst.contents AS install_script, COALESCE(pinst.contents, '') AS post_install_script, uninst.contents AS uninstall_script ` @@ -1266,6 +1277,22 @@ func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Co LEFT OUTER JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id` } + var tmID uint + if teamID != nil { + tmID = *teamID + } + + // nil installerID selects the first-added active package; otherwise that specific one. + whereClause := `si.title_id = ? AND si.global_or_team_id = ? + AND si.is_active = 1 +ORDER BY si.id ASC +LIMIT 1` + args := []any{titleID, tmID} + if installerID != nil { + whereClause = `si.id = ? AND si.title_id = ? AND si.global_or_team_id = ?` + args = []any{*installerID, titleID, tmID} + } + query := fmt.Sprintf(` SELECT si.id, @@ -1296,19 +1323,11 @@ FROM LEFT JOIN fleet_maintained_apps fma ON fma.id = si.fleet_maintained_app_id %s WHERE - si.title_id = ? AND si.global_or_team_id = ? - AND si.is_active = 1 -ORDER BY si.id ASC -LIMIT 1`, - scriptContentsSelect, scriptContentsFrom) - - var tmID uint - if teamID != nil { - tmID = *teamID - } + %s`, + scriptContentsSelect, scriptContentsFrom, whereClause) var dest fleet.SoftwareInstaller - err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, titleID, tmID) + err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, args...) if err != nil { if err == sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, notFound("SoftwareInstaller"), "get software installer metadata") @@ -1359,8 +1378,7 @@ LIMIT 1`, } func (ds *Datastore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { - // Script contents are joined in so the API detail shape (and the edit path, - // which reads the existing install script) has the full package. + // Join script contents so the detail shape and the edit path get the full package. const query = ` SELECT si.id, diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index ea87a333762..8758049cd56 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -484,15 +484,12 @@ func (ds *Datastore) processSoftwareTitleResults( } } - // Populate the multi-package packages[] array (active custom packages only). - // The main query returns a single installer row per title, so the full set - // is fetched separately here. + // The main query returns one installer row per title, so fetch the full package set separately. packagesByTitle, err := ds.GetSoftwarePackagesForTitles(ctx, opt.TeamID, titleIDs) if err != nil { return nil, 0, nil, ctxerr.Wrap(ctx, err, "get packages for software titles") } - // v1: automatic install policies are title-level, so attach the same set to - // every package. #48398 will attribute policies per-installer. + // Automatic install policies are title-level for now, so attach the same set to every package. policiesByTitle := make(map[uint][]fleet.AutomaticInstallPolicy, len(policies)) for _, p := range policies { policiesByTitle[p.TitleID] = append(policiesByTitle[p.TitleID], p) @@ -581,13 +578,11 @@ func (ds *Datastore) processSoftwareTitleResults( return titles, counts, metaData, nil } -// GetSoftwarePackagesForTitles returns the trimmed per-package information for -// every active custom package on the given titles, keyed by title id and -// ordered first-added first. It is used to populate the packages[] array on the -// list-titles response; the full per-package shape lives on the detail endpoint. -func (ds *Datastore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageOrApp, error) { +// GetSoftwarePackagesForTitles returns trimmed per-package info for the titles' +// active packages, keyed by title id, first-added first. Backs the list packages[]. +func (ds *Datastore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageListItem, error) { if len(titleIDs) == 0 { - return map[uint][]fleet.SoftwarePackageOrApp{}, nil + return map[uint][]fleet.SoftwarePackageListItem{}, nil } const stmt = ` @@ -613,7 +608,7 @@ ORDER BY si.id ASC` PackageURL *string `db:"package_url"` } - ret := make(map[uint][]fleet.SoftwarePackageOrApp) + ret := make(map[uint][]fleet.SoftwarePackageListItem) batchSize := 32000 err := common_mysql.BatchProcessSimple(titleIDs, batchSize, func(titleIDsToProcess []uint) error { query, args, err := sqlx.In(stmt, ptr.ValOrZero(teamID), titleIDsToProcess) @@ -626,7 +621,7 @@ ORDER BY si.id ASC` } for _, r := range rows { selfService := r.SelfService - ret[r.TitleID] = append(ret[r.TitleID], fleet.SoftwarePackageOrApp{ + ret[r.TitleID] = append(ret[r.TitleID], fleet.SoftwarePackageListItem{ Name: r.Name, Version: r.Version, Platform: r.Platform, diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 33e7c971e5b..f95c33f7450 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -845,10 +845,8 @@ type Datastore interface { // from the title IDs to the categories assigned to the installers for those titles. GetCategoriesForSoftwareTitles(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) - // GetCategoriesForSoftwareInstallers takes a set of software installer IDs and - // returns a map from installer ID to the categories assigned to that installer. - // Unlike GetCategoriesForSoftwareTitles, categories are not merged across a - // title's packages, so this supports per-package categories. + // GetCategoriesForSoftwareInstallers returns categories keyed by installer ID, + // unmerged (unlike GetCategoriesForSoftwareTitles) so packages keep their own. GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) // GetSoftwareTitlesForInstallAll returns the self-service software titles available @@ -2695,16 +2693,19 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) + // GetSoftwareInstallerMetadataByTeamTitleAndInstallerID is like + // GetSoftwareInstallerMetadataByTeamAndTitleID but returns a specific installer + // (not the first-added), so add/edit responses can echo the affected package. + GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*SoftwareInstaller, error) + // GetSoftwarePackagesByTeamAndTitleID returns every active package for the given // title and team, ordered first-added first, each with its label scope and // script contents. GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*SoftwareInstaller, error) - // GetSoftwarePackagesForTitles returns the trimmed per-package information for - // every active custom package on the given titles, keyed by title id and - // ordered first-added first. It backs the packages[] array on the list-titles - // response. - GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]SoftwarePackageOrApp, error) + // GetSoftwarePackagesForTitles returns trimmed per-package info for the titles' + // active packages, keyed by title id, first-added first; backs the list packages[]. + GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]SoftwarePackageListItem, error) // GetFleetMaintainedVersionsByTitleID returns all cached versions of a // fleet-maintained app for the given title and team. If byVersion is true diff --git a/server/fleet/software.go b/server/fleet/software.go index f12dcc54608..0a4b7b07396 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -439,13 +439,9 @@ type SoftwareTitle struct { // InHouseAppsCount is 0 or 1, indicating if the software title has // an in house app (.ipa) installer InHouseAppCount int `json:"-" db:"in_house_apps_count"` - // SoftwarePackage is the software installer information for this title. It is - // retained for backwards compatibility and holds the first-added (oldest) - // package; it is nil when the title has no packages. + // SoftwarePackage is kept for backwards compatibility; it holds the first-added package (nil when none). SoftwarePackage *SoftwareInstaller `json:"software_package" db:"-"` - // Packages holds every custom package added for this title (ordered - // first-added first). It is nil when the title has no packages, so it - // marshals to null. + // Packages holds every package, first-added first; nil (marshals to null) when none. Packages []SoftwareInstaller `json:"packages" db:"-"` // AppStoreApp is the VPP app information for this title. AppStoreApp *VPPAppStoreApp `json:"app_store_app" db:"-"` @@ -530,16 +526,11 @@ type SoftwareTitleListResult struct { // was last updated for that software title CountsUpdatedAt *time.Time `json:"-" db:"counts_updated_at"` - // SoftwarePackage provides software installer package information, it is - // only present if a software installer is available for the software title. - // It is retained for backwards compatibility and holds the first-added - // (oldest) package. + // SoftwarePackage is kept for backwards compatibility; it holds the first-added package (nil when none). SoftwarePackage *SoftwarePackageOrApp `json:"software_package"` - // Packages provides the trimmed package information for every custom package - // added for this title (ordered first-added first). It is nil (marshals to - // null) when the title has no packages. - Packages []SoftwarePackageOrApp `json:"packages"` + // Packages holds the trimmed per-package info, first-added first; nil (marshals to null) when none. + Packages []SoftwarePackageListItem `json:"packages"` // AppStoreApp provides VPP app information, it is only present if a VPP app // is available for the software title. diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index a8926be5b4c..c655d50338a 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -846,6 +846,17 @@ type SoftwarePackageOrApp struct { Categories []string `json:"categories,omitempty"` } +// SoftwarePackageListItem is the trimmed list-response package shape; it omits the +// host-only last_install/last_uninstall fields that SoftwarePackageOrApp carries. +type SoftwarePackageListItem struct { + Name string `json:"name"` + AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies"` + Version string `json:"version"` + Platform string `json:"platform"` + SelfService *bool `json:"self_service,omitempty"` + PackageURL *string `json:"package_url"` +} + func (s *SoftwarePackageOrApp) GetPlatform() string { return s.Platform } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 4aa0b06d825..4bbf80e7c7a 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1578,9 +1578,11 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc func(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) + type GetSoftwarePackagesByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) -type GetSoftwarePackagesForTitlesFunc func(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageOrApp, error) +type GetSoftwarePackagesForTitlesFunc func(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageListItem, error) type GetFleetMaintainedVersionsByTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) @@ -4476,6 +4478,9 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc + GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFuncInvoked bool + GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFuncInvoked bool @@ -10767,6 +10772,13 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + s.mu.Lock() + s.GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc(ctx, teamID, titleID, installerID, withScriptContents) +} + func (s *DataStore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { s.mu.Lock() s.GetSoftwarePackagesByTeamAndTitleIDFuncInvoked = true @@ -10774,7 +10786,7 @@ func (s *DataStore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, tea return s.GetSoftwarePackagesByTeamAndTitleIDFunc(ctx, teamID, titleID) } -func (s *DataStore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageOrApp, error) { +func (s *DataStore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageListItem, error) { s.mu.Lock() s.GetSoftwarePackagesForTitlesFuncInvoked = true s.mu.Unlock() diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 315c0ba8514..88eae37d0bf 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -17147,9 +17147,45 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareMultiplePackagesPerTitle() }, expectedStatus, expectedErr) } + // rawSoftwareMultipart adds or edits a script package and returns the raw + // response body, so we can assert exactly which package the endpoint echoes. + rawSoftwareMultipart := func(method, path, content string, extra map[string]string) []byte { + fr, err := fleet.NewTempFileReader(strings.NewReader(content), func() string { return t.TempDir() }) + require.NoError(t, err) + defer fr.Close() + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("software", "deploy.sh") + require.NoError(t, err) + _, err = io.Copy(fw, fr) + require.NoError(t, err) + require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", team.ID))) + require.NoError(t, w.WriteField("fleet_id", fmt.Sprintf("%d", team.ID))) + for k, v := range extra { + require.NoError(t, w.WriteField(k, v)) + } + require.NoError(t, w.Close()) + headers := map[string]string{ + "Content-Type": w.FormDataContentType(), + "Accept": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", s.token), + } + r := s.DoRawWithHeaders(method, path, body.Bytes(), http.StatusOK, headers) + defer r.Body.Close() + respBody, err := io.ReadAll(r.Body) + require.NoError(t, err) + return respBody + } + // Add a first package (A, not self-service), then a second (B, self-service). + // The POST response must echo the package that was just added (not the title's + // first-added one). upload(contentA, false, http.StatusOK, "") - upload(contentB, true, http.StatusOK, "") + postBody := rawSoftwareMultipart("POST", "/api/latest/fleet/software/package", contentB, map[string]string{"self_service": "true"}) + var addResp uploadSoftwareInstallerResponse + require.NoError(t, json.Unmarshal(postBody, &addResp)) + require.NotNil(t, addResp.SoftwarePackage) + require.Equal(t, hashOf(contentB), addResp.SoftwarePackage.StorageID, "POST echoes the just-added package, not first-added") // Adding a second package emits the same added_software activity. s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), ``, 0) @@ -17204,6 +17240,27 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareMultiplePackagesPerTitle() require.NotNil(t, lt.Packages[1].SelfService) require.True(t, *lt.Packages[1].SelfService) + // The list packages[] must omit the host-only last_install/last_uninstall fields. + listRes := s.Do("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, + "query", "deploy", "team_id", fmt.Sprintf("%d", team.ID)) + rawList, err := io.ReadAll(listRes.Body) + require.NoError(t, err) + listRes.Body.Close() + var rawListParsed struct { + SoftwareTitles []struct { + Packages []map[string]json.RawMessage `json:"packages"` + } `json:"software_titles"` + } + require.NoError(t, json.Unmarshal(rawList, &rawListParsed)) + require.Len(t, rawListParsed.SoftwareTitles, 1) + require.Len(t, rawListParsed.SoftwareTitles[0].Packages, 2) + for _, p := range rawListParsed.SoftwareTitles[0].Packages { + _, hasLastInstall := p["last_install"] + _, hasLastUninstall := p["last_uninstall"] + require.False(t, hasLastInstall, "list packages[] must omit last_install") + require.False(t, hasLastUninstall, "list packages[] must omit last_uninstall") + } + // --- Edit without installer_id on a multi-package title -> 400 --- s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ TitleID: titleID, @@ -17228,17 +17285,14 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareMultiplePackagesPerTitle() require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) require.Equal(t, hashOf(contentB), title.Packages[1].StorageID) - // --- Edit B's file to a new hash -> ok; A untouched --- - newFile, err := fleet.NewTempFileReader(strings.NewReader(contentC), func() string { return t.TempDir() }) - require.NoError(t, err) - defer newFile.Close() - s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ - TitleID: titleID, - InstallerID: installerB, - TeamID: &team.ID, - Filename: "deploy.sh", - InstallerFile: newFile, - }, http.StatusOK, "") + // --- Edit B's file to a new hash -> ok; A untouched; PATCH echoes the edited package --- + patchBody := rawSoftwareMultipart("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), + contentC, map[string]string{"installer_id": fmt.Sprintf("%d", installerB)}) + var patchResp getSoftwareInstallerResponse + require.NoError(t, json.Unmarshal(patchBody, &patchResp)) + require.NotNil(t, patchResp.SoftwareInstaller) + require.Equal(t, installerB, patchResp.SoftwareInstaller.InstallerID, "PATCH echoes the edited package, not first-added") + require.Equal(t, hashOf(contentC), patchResp.SoftwareInstaller.StorageID) title = getTitle() require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) require.Equal(t, hashOf(contentC), title.Packages[1].StorageID) diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 494b065c7aa..feb5a7a3332 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -43,8 +43,7 @@ type uploadSoftwareInstallerRequest struct { type updateSoftwareInstallerRequest struct { TitleID uint `url:"id"` - // InstallerID identifies which package to edit when the title has more than - // one; it is required in that case and optional for single-package titles. + // InstallerID selects which package to edit; required when the title has multiple. InstallerID *uint File *multipart.FileHeader TeamID *uint @@ -125,7 +124,6 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.TeamID = ptr.Uint(uint(fleetID)) } - // installer_id targets a specific package when the title has multiple. if idVal, ok := r.MultipartForm.Value["installer_id"]; ok && len(idVal) > 0 && idVal[0] != "" { installerID, err := strconv.ParseUint(idVal[0], 10, 32) if err != nil { @@ -514,8 +512,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. type deleteSoftwareInstallerRequest struct { TeamID *uint `query:"team_id" renameto:"fleet_id"` - // InstallerID deletes a single package; when omitted, all of the title's - // custom packages are deleted (title-level delete). + // InstallerID deletes one package; omitted deletes all of the title's packages. InstallerID *uint `query:"installer_id,optional"` TitleID uint `url:"title_id"` } diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 8becf5b053c..c3f47a5153f 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -197,9 +197,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint return nil, ctxerr.Wrap(ctx, err, "get software packages") } if len(pkgs) > 0 { - // Display name, icon, and automatic install policies are title-level - // (identical across a title's packages); fetch them once from the - // first-added package's full metadata. + // Display name, icon, and policies are title-level; fetch once from the first-added package. titleMeta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true) if err != nil && !fleet.IsNotFound(err) { return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") @@ -226,8 +224,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint if titleMeta != nil { pkg.DisplayName = titleMeta.DisplayName pkg.IconUrl = titleMeta.IconUrl - // v1: automatic install policies are title-level. #48398 will - // attribute them per-installer. + // Automatic install policies are title-level for now. pkg.AutomaticInstallPolicies = titleMeta.AutomaticInstallPolicies } @@ -255,8 +252,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint } } - // packages[] holds every package; software_package is retained for - // backwards compatibility and equals the first-added package. + // software_package is kept for backwards compatibility and equals the first-added package. software.Packages = make([]fleet.SoftwareInstaller, len(pkgs)) for i, pkg := range pkgs { software.Packages[i] = *pkg From 103c368d7fe23689045fab3038e245349610293b Mon Sep 17 00:00:00 2001 From: Carlo DiCelico Date: Thu, 2 Jul 2026 16:46:03 -0400 Subject: [PATCH 9/9] fix tests --- cmd/fleetctl/fleetctl/get_test.go | 4 ++++ server/service/integration_enterprise_test.go | 2 ++ 2 files changed, 6 insertions(+) diff --git a/cmd/fleetctl/fleetctl/get_test.go b/cmd/fleetctl/fleetctl/get_test.go index ba16742540a..21efddf3cae 100644 --- a/cmd/fleetctl/fleetctl/get_test.go +++ b/cmd/fleetctl/fleetctl/get_test.go @@ -1015,6 +1015,7 @@ spec: id: 0 name: foo software_package: null + packages: null source: chrome_extensions extension_for: chrome display_name: "" @@ -1040,6 +1041,7 @@ spec: id: 0 name: bar software_package: null + packages: null source: deb_packages extension_for: "" display_name: "" @@ -1091,6 +1093,7 @@ spec: } ], "software_package": null, + "packages": null, "app_store_app": null }, { @@ -1111,6 +1114,7 @@ spec: } ], "software_package": null, + "packages": null, "app_store_app": null } ] diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 88eae37d0bf..fef051acb4c 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -14295,6 +14295,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", fmt.Sprint(tm.ID)) titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = new(true) + titlesResp.SoftwareTitles[0].Packages[0].SelfService = new(true) require.Equal(t, titlesResp, newTitlesResp) // empty payload cleans the software items @@ -14349,6 +14350,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { newTitlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = new(true) + titlesResp.SoftwareTitles[0].Packages[0].SelfService = new(true) require.Equal(t, titlesResp, newTitlesResp) // create some labels A, B and C