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/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/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle.go new file mode 100644 index 00000000000..33789beb603 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle.go @@ -0,0 +1,103 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260702232839, Down_20260702232839) +} + +func Up_20260702232839(tx *sql.Tx) error { + // 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 + 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) + } + + // Collapse rows that would violate the new key: keep the first-added active row per group + // (the row the reads return), or the lowest id if none is active, and delete the rest. + // Re-point policies off the deleted rows first, since policies.software_installer_id is + // RESTRICT. Keep policies.updated_at so this content-identical swap doesn't read as a + // policy edit. + const dupGroups = ` + SELECT global_or_team_id, title_id, dedup_token, + COALESCE(MIN(CASE WHEN is_active = 1 THEN id END), 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, p.updated_at = p.updated_at + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("re-pointing policies off duplicate installers: %w", err) + } + + // setup_experience_software_installers has an ON DELETE CASCADE FK, so a selection that + // lived only on a deleted duplicate would be silently dropped. Re-point those rows onto the + // survivor first. UPDATE IGNORE skips a row when the survivor already has that platform. + if _, err := tx.Exec(fmt.Sprintf(` + UPDATE IGNORE setup_experience_software_installers sesi + JOIN software_installers si ON si.id = sesi.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 sesi.software_installer_id = dup.keep_id + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("re-pointing setup experience installers off duplicate installers: %w", err) + } + + // software_install_upcoming_activities has an ON DELETE SET NULL FK, so a queued install on + // a deleted duplicate would be silently orphaned. Re-point pending installs onto the survivor. + if _, err := tx.Exec(fmt.Sprintf(` + UPDATE software_install_upcoming_activities siua + JOIN software_installers si ON si.id = siua.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 siua.software_installer_id = dup.keep_id, siua.updated_at = siua.updated_at + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("re-pointing upcoming install activities 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_20260702232839(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle_test.go new file mode 100644 index 00000000000..faeda31f7ca --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle_test.go @@ -0,0 +1,194 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20260702232839(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 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 + 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) + defer r.Close() + for r.Next() { + var id int64 + require.NoError(t, r.Scan(&id)) + ids = append(ids, id) + } + require.NoError(t, r.Err()) + 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) + // Freeze updated_at so the re-point can be checked for not bumping it. + execNoErr(t, db, `UPDATE policies SET updated_at = '2020-01-01 00:00:00' WHERE id = ?`, policyID) + // A setup experience selection living only on the deleted row (FK is ON DELETE CASCADE). + execNoErr(t, db, `INSERT INTO setup_experience_software_installers (software_installer_id, platform, global_or_team_id) VALUES (?, 'windows', 0)`, dupB) + // A pending install queued on the deleted row (FK is ON DELETE SET NULL). + upcomingID := execNoErrLastID(t, db, `INSERT INTO upcoming_activities (host_id, activity_type, execution_id, payload) VALUES (1, 'software_install', 'dup-install-exec', '{}')`) + execNoErr(t, db, `INSERT INTO software_install_upcoming_activities (upcoming_activity_id, software_installer_id, software_title_id) VALUES (?, ?, ?)`, upcomingID, dupB, titleB) + + // 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) + + // Custom hash-duplicate where the first-added row is inactive and a later row is active. + // The active row must be the survivor to match the is_active reads, even though it is not + // the lowest id. A policy points at the inactive row that gets deleted. + titleF := insertTitle("AppF", "apps") + inactiveF := insertInstaller(titleF, nil, "darwin", "1.0", "hash-f", nil, 0) + activeF := insertInstaller(titleF, nil, "darwin", "2.0", "hash-f", nil, 1) + policyF := execNoErrLastID(t, db, ` + INSERT INTO policies (name, query, description, checksum, software_installer_id) + VALUES ('pf', 'SELECT 1', '', UNHEX(MD5('pf')), ?)`, inactiveF) + + applyNext(t, db) + + // 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' + 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) + 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.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, without bumping updated_at. + var repointed int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM policies WHERE id = ?`, policyID).Scan(&repointed)) + require.Equal(t, keepB, repointed) + var updatedAtUnchanged bool + require.NoError(t, db.QueryRow(`SELECT updated_at = '2020-01-01 00:00:00' FROM policies WHERE id = ?`, policyID).Scan(&updatedAtUnchanged)) + require.True(t, updatedAtUnchanged) + + // The setup experience selection on the deleted row was re-pointed to the survivor, not + // dropped by the ON DELETE CASCADE. + var setupExperienceInstaller int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM setup_experience_software_installers WHERE platform = 'windows'`).Scan(&setupExperienceInstaller)) + require.Equal(t, keepB, setupExperienceInstaller) + + // The pending install on the deleted row was re-pointed to the survivor, not orphaned by + // the ON DELETE SET NULL. + var upcomingInstaller int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM software_install_upcoming_activities WHERE upcoming_activity_id = ?`, upcomingID).Scan(&upcomingInstaller)) + require.Equal(t, keepB, upcomingInstaller) + + // FMA same-hash-different-version rows both survive. + require.Equal(t, []int64{fmaOld, fmaActive}, remainingIDs(titleD)) + + // The active row is retained over the lower-id inactive one, and the policy re-points to it. + require.Equal(t, []int64{activeF}, remainingIDs(titleF)) + var repointedF int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM policies WHERE id = ?`, policyF).Scan(&repointedF)) + require.Equal(t, activeF, repointedF) + + // 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)) + + // 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 68fd80aa309..8b32becb25a 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=560 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=561 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,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,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,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260702232839,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) CHARACTER SET utf8mb4 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`), diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 06ad664d41c..c65157795a8 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -200,14 +200,7 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload 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") + return 0, 0, err } // Insert in house app instead of software installer @@ -237,34 +230,20 @@ 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. + // 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) { - 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 _, 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.", + ) } } @@ -511,27 +490,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, tx sqlx.ExtContext, 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} } @@ -1298,7 +1297,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) @@ -1318,37 +1317,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") @@ -1384,6 +1357,86 @@ 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 packages []*fleet.SoftwareInstaller + 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") + } + + 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 @@ -4053,6 +4106,16 @@ LIMIT 1` } 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): @@ -4067,7 +4130,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 @@ -4076,7 +4139,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): @@ -4085,41 +4148,105 @@ 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 + } + if err != nil { + return err } - 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) + + // 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 ( + 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 if installer exists by name") + return ctxerr.Wrap(ctx, err, "check duplicate package by hash") } - if exists { - return alreadyExists("installer", payload.Title) + 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 fleet.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 >= fleet.MaxPackagesPerTitle { + return ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageLimitMessage, payload.Title, fleet.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 { + case payload.Platform == string(fleet.MacOSPlatform) && payload.BundleIdentifier != "": + 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 payload.Platform == "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 (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, 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 ced01a08d15..95349f006f9 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" @@ -41,6 +40,7 @@ func TestSoftwareInstallers(t *testing.T) { {"BatchSetSoftwareInstallersWithUpgradeCodes", testBatchSetSoftwareInstallersWithUpgradeCodes}, {"GetSoftwareInstallersPendingDeletion", testGetSoftwareInstallersPendingDeletion}, {"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID}, + {"GetSoftwarePackagesByTeamAndTitleID", testGetSoftwarePackagesByTeamAndTitleID}, {"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers}, {"DeleteSoftwareInstallers", testDeleteSoftwareInstallers}, {"testDeletePendingSoftwareInstallsForPolicy", testDeletePendingSoftwareInstallsForPolicy}, @@ -3777,16 +3777,25 @@ 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. + 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 { _, 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) + `, fma.ID, installer1NoTeam) return err }) @@ -4443,6 +4452,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() @@ -4479,11 +4541,8 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore // Duplicate on Team A with different name/title but same hash → reject _, _, 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.ErrorAs(t, err, &iae) // Same hash on different team → allowed _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamB.ID, "c.sh", "title-c")) @@ -4495,11 +4554,8 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore // Global scope second time (duplicate hash) → reject _, _, 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.ErrorAs(t, err, &iae2) // Test that binary packages (.pkg) with duplicate hash ARE allowed mkPkgPayload := func(teamID *uint, filename, title string) *fleet.UploadSoftwareInstallerPayload { @@ -4526,9 +4582,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 the within-title hash check _, _, 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) { @@ -5475,9 +5531,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) @@ -5533,7 +5590,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"` @@ -5547,8 +5604,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) } @@ -5768,7 +5825,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) @@ -5826,7 +5883,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", @@ -5855,9 +5912,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", @@ -5884,9 +5941,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", @@ -5916,7 +5973,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. @@ -5947,7 +6004,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. @@ -5978,9 +6035,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", @@ -6007,7 +6064,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-storage-arm", + 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-storage-intel", + 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 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), + 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", 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 8c4f1036f62..aa295ed20a4 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 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 si.filename = ?" $additionalWhere}} + {{$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}} @@ -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/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index adb9f041faa..68cc11dcddc 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,55 @@ 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)}} + + // 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) + 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 082d2e213b5..a4cb086f234 100644 Binary files a/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz and b/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz differ diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 55d1f35bb28..0e4a1ef72de 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2692,6 +2692,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 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 // 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/errors.go b/server/fleet/errors.go index ecba68a427c..aa5c4d5590c 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 = "%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 { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index c50a2baedbc..2b09fc27650 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1578,6 +1578,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) @@ -4472,6 +4474,9 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFunc + GetSoftwarePackagesByTeamAndTitleIDFuncInvoked bool + GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFuncInvoked bool @@ -10757,6 +10762,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 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{