diff --git a/cmd/fleetctl/fleetctl/get_test.go b/cmd/fleetctl/fleetctl/get_test.go index ba16742540a..21efddf3cae 100644 --- a/cmd/fleetctl/fleetctl/get_test.go +++ b/cmd/fleetctl/fleetctl/get_test.go @@ -1015,6 +1015,7 @@ spec: id: 0 name: foo software_package: null + packages: null source: chrome_extensions extension_for: chrome display_name: "" @@ -1040,6 +1041,7 @@ spec: id: 0 name: bar software_package: null + packages: null source: deb_packages extension_for: "" display_name: "" @@ -1091,6 +1093,7 @@ spec: } ], "software_package": null, + "packages": null, "app_store_app": null }, { @@ -1111,6 +1114,7 @@ spec: } ], "software_package": null, + "packages": null, "app_store_app": null } ] diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index f7ddf9c7b0b..d52998b04f0 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -212,7 +212,8 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. return addedInstaller, nil } - addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, true) + // Return the package just added, not the title's first-added one. + addedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), &tmID, titleID, installerID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting added software installer") } @@ -411,18 +412,49 @@ 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 { + 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.", } } + // Defaults to the first-added package; a specific installer_id overrides it below. existingInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting existing installer") } + // siblings is reused for both installer targeting and the hash-collision check below. + var siblings []*fleet.SoftwareInstaller + if software.SoftwareInstallersCount > 1 || payload.InstallerID != 0 { + siblings, err = svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, payload.TeamID, payload.TitleID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting title packages") + } + + switch { + case payload.InstallerID == 0 && software.SoftwareInstallersCount > 1: + return nil, &fleet.BadRequestError{ + Message: "installer_id is required when the title has multiple packages.", + } + case payload.InstallerID != 0 && payload.InstallerID != existingInstaller.InstallerID: + var target *fleet.SoftwareInstaller + for _, p := range siblings { + if p.InstallerID == payload.InstallerID { + target = p + break + } + } + if target == nil { + return nil, ctxerr.Wrapf(ctx, ¬FoundError{}, + "installer %d does not belong to this title and team", payload.InstallerID) + } + // Icon is title-level; carry it from the first-added read for the activity. + target.IconUrl = existingInstaller.IconUrl + existingInstaller = target + } + } + if payload.IsNoopPayload(software) { return existingInstaller, nil // no payload, noop } @@ -499,6 +531,15 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } if payloadForNewInstallerFile.StorageID != existingInstaller.StorageID { + // Catch a sibling hash match for a friendly 409; the dedup_token key would otherwise raise a raw 1062. + for _, p := range siblings { + if p.InstallerID != existingInstaller.InstallerID && p.StorageID == payloadForNewInstallerFile.StorageID { + return nil, ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageHashConflictMessage, payloadForNewInstallerFile.Filename), + }, "edit collides with sibling package hash") + } + } + activity.SoftwarePackage = &payload.Filename payload.StorageID = payloadForNewInstallerFile.StorageID payload.Filename = payloadForNewInstallerFile.Filename @@ -824,8 +865,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. } } - // re-pull installer from database to ensure any side effects are accounted for; may be able to optimize this out later - updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, true) + // re-pull the edited installer to reflect side effects; return that specific + // package, not the title's first-added one. May be able to optimize this out later. + updatedInstaller, err := svc.ds.GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctxdb.RequirePrimary(ctx, true), payload.TeamID, payload.TitleID, payload.InstallerID, true) if err != nil { return nil, ctxerr.Wrap(ctx, err, "re-hydrating updated installer metadata") } @@ -916,7 +958,7 @@ func ValidateSoftwareLabelsForUpdate(ctx context.Context, svc fleet.Service, exi return false, nil, nil } -func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error { if teamID == nil { return fleet.NewInvalidArgumentError("fleet_id", "is required") } @@ -927,7 +969,7 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return err } - // first, look for a software installer + // metaInstaller is fully hydrated (incl. the title-level icon) which the per-package reads below lack. metaInstaller, errInstaller := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, titleID, false) metaVPP, errVPP := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, teamID, titleID) metaInHouse, errInHouse := svc.ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, titleID) @@ -941,9 +983,40 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t return ctxerr.Wrap(ctx, errInHouse, "getting in house app metadata") } + // An installer id always refers to a software installer, never a VPP or in-house app. + if installerID != nil { + if metaInstaller == nil { + return ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", *installerID) + } + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting title packages") + } + for _, pkg := range pkgs { + if pkg.InstallerID == *installerID { + pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity + return svc.deleteSoftwareInstaller(ctx, pkg) + } + } + return ctxerr.Wrapf(ctx, ¬FoundError{}, "installer %d does not belong to this title and team", *installerID) + } + switch { case metaInstaller != nil: - return svc.deleteSoftwareInstaller(ctx, metaInstaller) + // Delete every package on the title. FMA titles keep one active row, so this + // matches prior behavior for them. Per-package deletes mean a guarded package + // (setup experience / patch policy) fails the title delete partway. + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting title packages to delete") + } + for _, pkg := range pkgs { + pkg.IconUrl = metaInstaller.IconUrl // title-level icon for cleanup + activity + if err := svc.deleteSoftwareInstaller(ctx, pkg); err != nil { + return err + } + } + return nil case metaVPP != nil: return svc.deleteVPPApp(ctx, teamID, metaVPP) case metaInHouse != nil: diff --git a/server/datastore/mysql/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/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go new file mode 100644 index 00000000000..792d2a3a121 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -0,0 +1,71 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260629163945, Down_20260629163945) +} + +func Up_20260629163945(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 lowest id per group and delete + // the rest. Re-point policies off the deleted rows first, since + // policies.software_installer_id is RESTRICT. + const dupGroups = ` + SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id + FROM software_installers + WHERE title_id IS NOT NULL + GROUP BY global_or_team_id, title_id, dedup_token + HAVING COUNT(*) > 1` + + if _, err := tx.Exec(fmt.Sprintf(` + UPDATE policies p + JOIN software_installers si ON si.id = p.software_installer_id + JOIN (%s) dup + ON si.global_or_team_id = dup.global_or_team_id + AND si.title_id = dup.title_id + AND si.dedup_token = dup.dedup_token + SET p.software_installer_id = dup.keep_id + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("re-pointing policies off duplicate installers: %w", err) + } + + if _, err := tx.Exec(fmt.Sprintf(` + DELETE si FROM software_installers si + JOIN (%s) dup + ON si.global_or_team_id = dup.global_or_team_id + AND si.title_id = dup.title_id + AND si.dedup_token = dup.dedup_token + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("deleting duplicate installers: %w", err) + } + + if _, err := tx.Exec(` + ALTER TABLE software_installers + DROP INDEX idx_software_installers_team_title_version, + ADD UNIQUE KEY idx_software_installers_dedup (global_or_team_id, title_id, dedup_token) + `); err != nil { + return fmt.Errorf("swapping software_installers unique key: %w", err) + } + + return nil +} + +func Down_20260629163945(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go new file mode 100644 index 00000000000..f15eb7ee69b --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -0,0 +1,156 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20260629163945(t *testing.T) { + db := applyUpToPrev(t) + + insertTitle := func(name string, source string) int64 { + return execNoErrLastID(t, db, `INSERT INTO software_titles (name, source) VALUES (?, ?)`, name, source) + } + + const installerInsert = ` + INSERT INTO software_installers + (team_id, global_or_team_id, title_id, filename, extension, version, platform, + install_script_content_id, uninstall_script_content_id, storage_id, package_ids, patch_query, + fleet_maintained_app_id, is_active) + VALUES (?, ?, ?, ?, 'pkg', ?, ?, ?, ?, ?, '', '', ?, ?)` + + insertScript := func(seed string) int64 { + return execNoErrLastID(t, db, `INSERT INTO script_contents (contents, md5_checksum) VALUES ('#!/bin/sh', UNHEX(MD5(?)))`, seed) + } + + // teamID nil means no-team 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) + + // Custom hash-duplicate scoped to a team, different source. + titleC := insertTitle("AppC", "rpm_packages") + keepC := insertInstaller(titleC, &team, "linux", "1.0", "hash-c", nil, 1) + dupC := insertInstaller(titleC, &team, "linux", "1.1", "hash-c", nil, 1) + + // FMA with the same bytes backing two versions. Tokens resolve to version, so both + // must survive. + titleD := insertTitle("AppD", "apps") + fmaOld := insertInstaller(titleD, nil, "darwin", "1.0", "hash-d", &fma, 0) + fmaActive := insertInstaller(titleD, nil, "darwin", "2.0", "hash-d", &fma, 1) + + applyNext(t, db) + + // The version key is gone, replaced by the 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. + var repointed int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM policies WHERE id = ?`, policyID).Scan(&repointed)) + require.Equal(t, keepB, repointed) + + // FMA same-hash-different-version rows both survive. + require.Equal(t, []int64{fmaOld, fmaActive}, remainingIDs(titleD)) + + // New key behavior. Custom same-version-different-hash 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 925c9cd199b..8f11728fdb3 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -2089,9 +2089,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=559 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=560 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260611202649,1,'2020-01-01 01:01:01'),(549,20260615135619,1,'2020-01-01 01:01:01'),(550,20260617172853,1,'2020-01-01 01:01:01'),(551,20260617194413,1,'2020-01-01 01:01:01'),(552,20260622124714,1,'2020-01-01 01:01:01'),(553,20260622124734,1,'2020-01-01 01:01:01'),(554,20260623140135,1,'2020-01-01 01:01:01'),(555,20260624152755,1,'2020-01-01 01:01:01'),(556,20260624210253,1,'2020-01-01 01:01:01'),(557,20260624210311,1,'2020-01-01 01:01:01'),(558,20260626120000,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260611202649,1,'2020-01-01 01:01:01'),(549,20260615135619,1,'2020-01-01 01:01:01'),(550,20260617172853,1,'2020-01-01 01:01:01'),(551,20260617194413,1,'2020-01-01 01:01:01'),(552,20260622124714,1,'2020-01-01 01:01:01'),(553,20260622124734,1,'2020-01-01 01:01:01'),(554,20260623140135,1,'2020-01-01 01:01:01'),(555,20260624152755,1,'2020-01-01 01:01:01'),(556,20260624210253,1,'2020-01-01 01:01:01'),(557,20260624210311,1,'2020-01-01 01:01:01'),(558,20260626120000,1,'2020-01-01 01:01:01'),(559,20260629163945,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -3034,8 +3034,9 @@ CREATE TABLE `software_installers` ( `is_active` tinyint(1) NOT NULL DEFAULT '0', `patch_query` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `http_etag` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `dedup_token` varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (if((`fleet_maintained_app_id` is null),`storage_id`,`version`)) VIRTUAL, PRIMARY KEY (`id`), - UNIQUE KEY `idx_software_installers_team_title_version` (`global_or_team_id`,`title_id`,`version`), + UNIQUE KEY `idx_software_installers_dedup` (`global_or_team_id`,`title_id`,`dedup_token`), KEY `fk_software_installers_title` (`title_id`), KEY `fk_software_installers_install_script_content_id` (`install_script_content_id`), KEY `fk_software_installers_post_install_script_content_id` (`post_install_script_content_id`), diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 76cccd05935..ac6f49782f2 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -7165,3 +7165,41 @@ WHERE return ret, nil } + +// GetCategoriesForSoftwareInstallers returns categories keyed by installer id, +// unmerged (unlike GetCategoriesForSoftwareTitles) so packages keep their own. +func (ds *Datastore) GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) { + if len(installerIDs) == 0 { + return map[uint][]string{}, nil + } + + stmt := ` +SELECT + sisc.software_installer_id AS installer_id, + sc.name AS software_category_name +FROM + software_installer_software_categories sisc + JOIN software_categories sc ON sc.id = sisc.software_category_id +WHERE + sisc.software_installer_id IN (?) +ORDER BY sc.name` + + stmt, args, err := sqlx.In(stmt, installerIDs) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "sqlx.In for get categories for software installers by id") + } + var categories []struct { + InstallerID uint `db:"installer_id"` + CategoryName string `db:"software_category_name"` + } + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &categories, stmt, args...); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get categories for software installers by id") + } + + ret := make(map[uint][]string, len(categories)) + for _, c := range categories { + ret[c.InstallerID] = append(ret[c.InstallerID], c.CategoryName) + } + + return ret, nil +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index b51e206d944..9ea993fd6e6 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, 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} } @@ -1259,6 +1258,17 @@ WHERE } func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + return ds.getSoftwareInstallerMetadata(ctx, teamID, titleID, nil, withScriptContents) +} + +// GetSoftwareInstallerMetadataByTeamTitleAndInstallerID returns the fully-hydrated +// metadata for a specific installer (rather than the first-added one), so add/edit +// responses can echo the affected package. +func (ds *Datastore) GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + return ds.getSoftwareInstallerMetadata(ctx, teamID, titleID, &installerID, withScriptContents) +} + +func (ds *Datastore) getSoftwareInstallerMetadata(ctx context.Context, teamID *uint, titleID uint, installerID *uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { var scriptContentsSelect, scriptContentsFrom string if withScriptContents { scriptContentsSelect = ` , inst.contents AS install_script, COALESCE(pinst.contents, '') AS post_install_script, uninst.contents AS uninstall_script ` @@ -1267,6 +1277,22 @@ func (ds *Datastore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Co LEFT OUTER JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id` } + var tmID uint + if teamID != nil { + tmID = *teamID + } + + // nil installerID selects the first-added active package; otherwise that specific one. + whereClause := `si.title_id = ? AND si.global_or_team_id = ? + AND si.is_active = 1 +ORDER BY si.id ASC +LIMIT 1` + args := []any{titleID, tmID} + if installerID != nil { + whereClause = `si.id = ? AND si.title_id = ? AND si.global_or_team_id = ?` + args = []any{*installerID, titleID, tmID} + } + query := fmt.Sprintf(` SELECT si.id, @@ -1297,19 +1323,11 @@ FROM LEFT JOIN fleet_maintained_apps fma ON fma.id = si.fleet_maintained_app_id %s WHERE - si.title_id = ? AND si.global_or_team_id = ? - AND si.is_active = 1 -ORDER BY si.uploaded_at DESC, si.id DESC -LIMIT 1`, - scriptContentsSelect, scriptContentsFrom) - - var tmID uint - if teamID != nil { - tmID = *teamID - } + %s`, + scriptContentsSelect, scriptContentsFrom, whereClause) var dest fleet.SoftwareInstaller - err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, titleID, tmID) + err := sqlx.GetContext(ctx, ds.reader(ctx), &dest, query, args...) if err != nil { if err == sql.ErrNoRows { return nil, ctxerr.Wrap(ctx, notFound("SoftwareInstaller"), "get software installer metadata") @@ -1319,36 +1337,10 @@ 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)) - } - } - - var count int - for _, set := range [][]fleet.SoftwareScopeLabel{exclAny, inclAny, inclAll} { - if len(set) > 0 { - count++ - } + return nil, err } - 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 { @@ -1385,6 +1377,93 @@ LIMIT 1`, return &dest, nil } +func (ds *Datastore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + // Join script contents so the detail shape and the edit path get the full package. + 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, + inst.contents AS install_script, + COALESCE(pinst.contents, '') AS post_install_script, + uninst.contents AS uninstall_script +FROM + software_installers si + JOIN software_titles st ON st.id = si.title_id + LEFT OUTER JOIN script_contents inst ON inst.id = si.install_script_content_id + LEFT OUTER JOIN script_contents pinst ON pinst.id = si.post_install_script_content_id + LEFT OUTER JOIN script_contents uninst ON uninst.id = si.uninstall_script_content_id +WHERE + si.title_id = ? AND si.global_or_team_id = ? + AND si.is_active = 1 +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 @@ -4109,6 +4188,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): @@ -4123,7 +4212,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return ctxerr.Wrap(ctx, err, "check if VPP app exists for title identifier") } if exists { - return alreadyExists("VPP app", payload.Title) + return conflict(fleet.SoftwareAlreadyHasVPPAppMessage) } // check if equivalent installers exist, duplicate in-house apps are checked in insertInHouseApp @@ -4132,7 +4221,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return ctxerr.Wrap(ctx, err, "check if software installer exists for title identifier") } if exists { - return alreadyExists("software installer", payload.Title) + return conflict(fleet.SoftwareAlreadyHasPackageMessage) } } case string(fleet.MacOSPlatform): @@ -4141,41 +4230,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 } - case "windows", "linux": - // check by name before any software title renaming side effects can happen - exists, err := ds.checkInstallerExistsByName(ctx, ds.reader(ctx), payload.TeamID, payload.Title, payload.Source, payload.Platform) if err != nil { - return ctxerr.Wrap(ctx, err, "check if installer exists by name") + return err } - if exists { - return alreadyExists("installer", payload.Title) + + // 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 duplicate package by hash") + } + if dup { + return ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageHashConflictMessage, payload.Filename), + }, "duplicate package by hash") } - if payload.UpgradeCode != "" { - exists, err := ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.UpgradeCode, payload.Platform, softwareTypeInstaller) - if err != nil { - return ctxerr.Wrap(ctx, err, "check if installer exists for upgrade code") - } - if exists { - return alreadyExists("installer", payload.Title) - } + // a title holds at most 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 payload.Platform { + case string(fleet.MacOSPlatform): + stmt = ` + SELECT EXISTS ( + SELECT 1 + FROM software_installers si + JOIN software_titles st ON st.id = si.title_id + WHERE si.global_or_team_id = ? AND st.source = ? AND st.bundle_identifier = ? + AND (si.fleet_maintained_app_id IS NOT NULL) = ? + )` + args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.BundleIdentifier, wantFMA} + case "windows": + stmt = ` + SELECT EXISTS ( + SELECT 1 + FROM software_installers si + JOIN software_titles st ON st.id = si.title_id + WHERE si.global_or_team_id = ? AND st.source = ? AND (st.name = ? OR (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 7092f3508e0..57c68f06327 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}, @@ -3728,16 +3728,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 }) @@ -4394,6 +4403,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() @@ -4430,11 +4492,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")) @@ -4446,11 +4505,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 { @@ -4477,9 +4533,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) { @@ -5426,9 +5482,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) @@ -5484,7 +5541,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"` @@ -5498,8 +5555,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) } @@ -5719,7 +5776,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()}) require.NoError(t, err) - const conflictMsg = "already has an installer available for" + const conflictMsg = "already has an Apple App Store (VPP) on" // macOS installer conflicting with a VPP app on the same bundle id. test.CreateInsertGlobalVPPToken(t, ds) @@ -5777,7 +5834,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data }) require.NoError(t, err) - // macOS installer conflicting with the same installer at a newer version. + // macOS: a second version of the same title is allowed (multiple packages per title). _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "mac-base-storage", Filename: "mac-app.pkg", @@ -5806,9 +5863,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) - // Windows installer conflicting with the same Title at a newer version. + // Windows: a second version of the same title is allowed. _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "win-base-storage", Filename: "win-app.msi", @@ -5835,9 +5892,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) - // Windows installer conflicting on the upgrade code with a different Title. + // Windows: a second package matching the same upgrade code is allowed. const winUpgradeCode = "{ABCDEF12-3456-7890-ABCD-EF1234567890}" _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "win-uc-base-storage", @@ -5867,7 +5924,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) // Windows: existing installer has an upgrade code, new upload has the same // Title but no upgrade code. @@ -5898,7 +5955,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) // Reverse: existing installer has no upgrade code, new upload has the same // Title with an upgrade code. @@ -5929,9 +5986,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) - // Linux installer conflicting with the same Title at a newer version. + // Linux: a second version of the same title is allowed. _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "linux-base-storage", Filename: "linux-app.deb", @@ -5958,7 +6015,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..8758049cd56 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 @@ -483,6 +483,27 @@ func (ds *Datastore) processSoftwareTitleResults( softwareList[i].DisplayName = displayName } } + + // The main query returns one installer row per title, so fetch the full package set separately. + packagesByTitle, err := ds.GetSoftwarePackagesForTitles(ctx, opt.TeamID, titleIDs) + if err != nil { + return nil, 0, nil, ctxerr.Wrap(ctx, err, "get packages for software titles") + } + // Automatic install policies are title-level for now, so attach the same set to every package. + policiesByTitle := make(map[uint][]fleet.AutomaticInstallPolicy, len(policies)) + for _, p := range policies { + policiesByTitle[p.TitleID] = append(policiesByTitle[p.TitleID], p) + } + for titleID, pkgs := range packagesByTitle { + i, ok := titleIndex[titleID] + if !ok { + continue + } + for j := range pkgs { + pkgs[j].AutomaticInstallPolicies = policiesByTitle[titleID] + } + softwareList[i].Packages = pkgs + } } // Fetch matching versions separately to avoid aggregating nested arrays in the main query. @@ -557,6 +578,66 @@ func (ds *Datastore) processSoftwareTitleResults( return titles, counts, metaData, nil } +// GetSoftwarePackagesForTitles returns trimmed per-package info for the titles' +// active packages, keyed by title id, first-added first. Backs the list packages[]. +func (ds *Datastore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageListItem, error) { + if len(titleIDs) == 0 { + return map[uint][]fleet.SoftwarePackageListItem{}, nil + } + + const stmt = ` +SELECT + si.title_id, + si.filename AS name, + si.version, + si.platform, + si.self_service, + si.url AS package_url +FROM + software_installers si +WHERE + si.global_or_team_id = ? AND si.is_active = 1 AND si.title_id IN (?) +ORDER BY si.id ASC` + + type packageRow struct { + TitleID uint `db:"title_id"` + Name string `db:"name"` + Version string `db:"version"` + Platform string `db:"platform"` + SelfService bool `db:"self_service"` + PackageURL *string `db:"package_url"` + } + + ret := make(map[uint][]fleet.SoftwarePackageListItem) + batchSize := 32000 + err := common_mysql.BatchProcessSimple(titleIDs, batchSize, func(titleIDsToProcess []uint) error { + query, args, err := sqlx.In(stmt, ptr.ValOrZero(teamID), titleIDsToProcess) + if err != nil { + return ctxerr.Wrap(ctx, err, "sqlx.In for get packages for titles") + } + var rows []packageRow + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &rows, query, args...); err != nil { + return ctxerr.Wrap(ctx, err, "get packages for titles") + } + for _, r := range rows { + selfService := r.SelfService + ret[r.TitleID] = append(ret[r.TitleID], fleet.SoftwarePackageListItem{ + Name: r.Name, + Version: r.Version, + Platform: r.Platform, + SelfService: &selfService, + PackageURL: r.PackageURL, + }) + } + return nil + }) + if err != nil { + return nil, err + } + + return ret, nil +} + // spliceSecondaryOrderBySoftwareTitlesSQL adds a secondary order by clause, splicing it into the // existing order by clause. This is necessary because multicolumn sort is not // supported by appendListOptionsWithCursorToSQL. @@ -632,7 +713,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 +766,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 +1020,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 0295833e1d1..f95c33f7450 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -845,6 +845,10 @@ type Datastore interface { // from the title IDs to the categories assigned to the installers for those titles. GetCategoriesForSoftwareTitles(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) + // GetCategoriesForSoftwareInstallers returns categories keyed by installer ID, + // unmerged (unlike GetCategoriesForSoftwareTitles) so packages keep their own. + GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) + // GetSoftwareTitlesForInstallAll returns the self-service software titles available // to queue for the host's "install all" action, in alphabetical order, optionally // scoped to a category. @@ -2689,6 +2693,20 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) + // GetSoftwareInstallerMetadataByTeamTitleAndInstallerID is like + // GetSoftwareInstallerMetadataByTeamAndTitleID but returns a specific installer + // (not the first-added), so add/edit responses can echo the affected package. + GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*SoftwareInstaller, error) + + // GetSoftwarePackagesByTeamAndTitleID returns every active package for the given + // title and team, ordered first-added first, each with its label scope and + // script contents. + GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*SoftwareInstaller, error) + + // GetSoftwarePackagesForTitles returns trimmed per-package info for the titles' + // active packages, keyed by title id, first-added first; backs the list packages[]. + GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]SoftwarePackageListItem, error) + // GetFleetMaintainedVersionsByTitleID returns all cached versions of a // fleet-maintained app for the given title and team. If byVersion is true // 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/service.go b/server/fleet/service.go index 607767b1ce1..429ceafa652 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1391,7 +1391,7 @@ type Service interface { UploadSoftwareInstaller(ctx context.Context, payload *UploadSoftwareInstallerPayload) (*SoftwareInstaller, error) UpdateSoftwareInstaller(ctx context.Context, payload *UpdateSoftwareInstallerPayload) (*SoftwareInstaller, error) - DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error + DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) GetSoftwareInstallerTokenMetadata(ctx context.Context, token string, titleID uint) (*SoftwareInstallerTokenMetadata, error) GetSoftwareInstallerMetadata(ctx context.Context, skipAuthz bool, titleID uint, teamID *uint) (*SoftwareInstaller, error) diff --git a/server/fleet/software.go b/server/fleet/software.go index c42c9cf4993..0a4b7b07396 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -439,8 +439,10 @@ type SoftwareTitle struct { // InHouseAppsCount is 0 or 1, indicating if the software title has // an in house app (.ipa) installer InHouseAppCount int `json:"-" db:"in_house_apps_count"` - // SoftwarePackage is the software installer information for this title. + // SoftwarePackage is kept for backwards compatibility; it holds the first-added package (nil when none). SoftwarePackage *SoftwareInstaller `json:"software_package" db:"-"` + // Packages holds every package, first-added first; nil (marshals to null) when none. + Packages []SoftwareInstaller `json:"packages" db:"-"` // AppStoreApp is the VPP app information for this title. AppStoreApp *VPPAppStoreApp `json:"app_store_app" db:"-"` // BundleIdentifier is used by Apple installers to uniquely identify @@ -524,10 +526,12 @@ type SoftwareTitleListResult struct { // was last updated for that software title CountsUpdatedAt *time.Time `json:"-" db:"counts_updated_at"` - // SoftwarePackage provides software installer package information, it is - // only present if a software installer is available for the software title. + // SoftwarePackage is kept for backwards compatibility; it holds the first-added package (nil when none). SoftwarePackage *SoftwarePackageOrApp `json:"software_package"` + // Packages holds the trimmed per-package info, first-added first; nil (marshals to null) when none. + Packages []SoftwarePackageListItem `json:"packages"` + // AppStoreApp provides VPP app information, it is only present if a VPP app // is available for the software title. AppStoreApp *SoftwarePackageOrApp `json:"app_store_app"` diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index d1827410059..c655d50338a 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -846,6 +846,17 @@ type SoftwarePackageOrApp struct { Categories []string `json:"categories,omitempty"` } +// SoftwarePackageListItem is the trimmed list-response package shape; it omits the +// host-only last_install/last_uninstall fields that SoftwarePackageOrApp carries. +type SoftwarePackageListItem struct { + Name string `json:"name"` + AutomaticInstallPolicies []AutomaticInstallPolicy `json:"automatic_install_policies"` + Version string `json:"version"` + Platform string `json:"platform"` + SelfService *bool `json:"self_service,omitempty"` + PackageURL *string `json:"package_url"` +} + func (s *SoftwarePackageOrApp) GetPlatform() string { return s.Platform } @@ -1195,6 +1206,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 faac330823d..4bbf80e7c7a 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -612,6 +612,8 @@ type GetSoftwareCategoryNameToIDMapFunc func(ctx context.Context, teamID uint, n type GetCategoriesForSoftwareTitlesFunc func(ctx context.Context, softwareTitleIDs []uint, team_id *uint) (map[uint][]string, error) +type GetCategoriesForSoftwareInstallersFunc func(ctx context.Context, installerIDs []uint) (map[uint][]string, error) + type GetSoftwareTitlesForInstallAllFunc func(ctx context.Context, host *fleet.Host, categoryID *uint) ([]*fleet.HostSoftwareWithInstaller, *string, error) type AssociateMDMInstallToVerificationUUIDFunc func(ctx context.Context, installUUID string, verifyCommandUUID string, hostUUID string) error @@ -1576,6 +1578,12 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc func(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) + +type GetSoftwarePackagesByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) + +type GetSoftwarePackagesForTitlesFunc func(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageListItem, 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) @@ -3021,6 +3029,9 @@ type DataStore struct { GetCategoriesForSoftwareTitlesFunc GetCategoriesForSoftwareTitlesFunc GetCategoriesForSoftwareTitlesFuncInvoked bool + GetCategoriesForSoftwareInstallersFunc GetCategoriesForSoftwareInstallersFunc + GetCategoriesForSoftwareInstallersFuncInvoked bool + GetSoftwareTitlesForInstallAllFunc GetSoftwareTitlesForInstallAllFunc GetSoftwareTitlesForInstallAllFuncInvoked bool @@ -4467,6 +4478,15 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc + GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFuncInvoked bool + + GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFunc + GetSoftwarePackagesByTeamAndTitleIDFuncInvoked bool + + GetSoftwarePackagesForTitlesFunc GetSoftwarePackagesForTitlesFunc + GetSoftwarePackagesForTitlesFuncInvoked bool + GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFuncInvoked bool @@ -7371,6 +7391,13 @@ func (s *DataStore) GetCategoriesForSoftwareTitles(ctx context.Context, software return s.GetCategoriesForSoftwareTitlesFunc(ctx, softwareTitleIDs, team_id) } +func (s *DataStore) GetCategoriesForSoftwareInstallers(ctx context.Context, installerIDs []uint) (map[uint][]string, error) { + s.mu.Lock() + s.GetCategoriesForSoftwareInstallersFuncInvoked = true + s.mu.Unlock() + return s.GetCategoriesForSoftwareInstallersFunc(ctx, installerIDs) +} + func (s *DataStore) GetSoftwareTitlesForInstallAll(ctx context.Context, host *fleet.Host, categoryID *uint) ([]*fleet.HostSoftwareWithInstaller, *string, error) { s.mu.Lock() s.GetSoftwareTitlesForInstallAllFuncInvoked = true @@ -10745,6 +10772,27 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) GetSoftwareInstallerMetadataByTeamTitleAndInstallerID(ctx context.Context, teamID *uint, titleID uint, installerID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) { + s.mu.Lock() + s.GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareInstallerMetadataByTeamTitleAndInstallerIDFunc(ctx, teamID, titleID, installerID, withScriptContents) +} + +func (s *DataStore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + s.mu.Lock() + s.GetSoftwarePackagesByTeamAndTitleIDFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwarePackagesByTeamAndTitleIDFunc(ctx, teamID, titleID) +} + +func (s *DataStore) GetSoftwarePackagesForTitles(ctx context.Context, teamID *uint, titleIDs []uint) (map[uint][]fleet.SoftwarePackageListItem, error) { + s.mu.Lock() + s.GetSoftwarePackagesForTitlesFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwarePackagesForTitlesFunc(ctx, teamID, titleIDs) +} + func (s *DataStore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) { s.mu.Lock() s.GetFleetMaintainedVersionsByTitleIDFuncInvoked = true diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 9944ff3d3aa..79e7117f30a 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -838,7 +838,7 @@ type UploadSoftwareInstallerFunc func(ctx context.Context, payload *fleet.Upload type UpdateSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) -type DeleteSoftwareInstallerFunc func(ctx context.Context, titleID uint, teamID *uint) error +type DeleteSoftwareInstallerFunc func(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error type GenerateSoftwareInstallerTokenFunc func(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) @@ -5197,11 +5197,11 @@ func (s *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.Up return s.UpdateSoftwareInstallerFunc(ctx, payload) } -func (s *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (s *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error { s.mu.Lock() s.DeleteSoftwareInstallerFuncInvoked = true s.mu.Unlock() - return s.DeleteSoftwareInstallerFunc(ctx, titleID, teamID) + return s.DeleteSoftwareInstallerFunc(ctx, titleID, teamID, installerID) } func (s *Service) GenerateSoftwareInstallerToken(ctx context.Context, alt string, titleID uint, teamID *uint) (string, error) { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 31160180674..fef051acb4c 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)) @@ -14295,6 +14295,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", fmt.Sprint(tm.ID)) titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = new(true) + titlesResp.SoftwareTitles[0].Packages[0].SelfService = new(true) require.Equal(t, titlesResp, newTitlesResp) // empty payload cleans the software items @@ -14349,6 +14350,7 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { newTitlesResp = listSoftwareTitlesResponse{} s.DoJSON("GET", "/api/v1/fleet/software/titles", nil, http.StatusOK, &newTitlesResp, "available_for_install", "true", "team_id", strconv.Itoa(int(0))) titlesResp.SoftwareTitles[0].SoftwarePackage.SelfService = new(true) + titlesResp.SoftwareTitles[0].Packages[0].SelfService = new(true) require.Equal(t, titlesResp, newTitlesResp) // create some labels A, B and C @@ -17120,6 +17122,217 @@ func (s *integrationEnterpriseTestSuite) TestScriptPackageUploads() { require.Empty(t, storedURL, "cache-hit re-apply must drop the placeholder url too") } +func (s *integrationEnterpriseTestSuite) TestSoftwareMultiplePackagesPerTitle() { + t := s.T() + ctx := context.Background() + + team, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team1"}) + require.NoError(t, err) + + // Two script packages with the same filename resolve to the same title + // ("deploy"), and different contents give them distinct content hashes, so + // they coexist as two packages under one title. + contentA := "#!/bin/bash\necho 'A'\n" + contentB := "#!/bin/bash\necho 'B'\n" + contentC := "#!/bin/bash\necho 'C'\n" + hashOf := func(s string) string { sum := sha256.Sum256([]byte(s)); return hex.EncodeToString(sum[:]) } + + upload := func(content string, selfService bool, expectedStatus int, expectedErr string) { + fr, err := fleet.NewTempFileReader(strings.NewReader(content), func() string { return t.TempDir() }) + require.NoError(t, err) + defer fr.Close() + s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{ + Filename: "deploy.sh", + TeamID: &team.ID, + SelfService: selfService, + InstallerFile: fr, + }, expectedStatus, expectedErr) + } + + // rawSoftwareMultipart adds or edits a script package and returns the raw + // response body, so we can assert exactly which package the endpoint echoes. + rawSoftwareMultipart := func(method, path, content string, extra map[string]string) []byte { + fr, err := fleet.NewTempFileReader(strings.NewReader(content), func() string { return t.TempDir() }) + require.NoError(t, err) + defer fr.Close() + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("software", "deploy.sh") + require.NoError(t, err) + _, err = io.Copy(fw, fr) + require.NoError(t, err) + require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", team.ID))) + require.NoError(t, w.WriteField("fleet_id", fmt.Sprintf("%d", team.ID))) + for k, v := range extra { + require.NoError(t, w.WriteField(k, v)) + } + require.NoError(t, w.Close()) + headers := map[string]string{ + "Content-Type": w.FormDataContentType(), + "Accept": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", s.token), + } + r := s.DoRawWithHeaders(method, path, body.Bytes(), http.StatusOK, headers) + defer r.Body.Close() + respBody, err := io.ReadAll(r.Body) + require.NoError(t, err) + return respBody + } + + // Add a first package (A, not self-service), then a second (B, self-service). + // The POST response must echo the package that was just added (not the title's + // first-added one). + upload(contentA, false, http.StatusOK, "") + postBody := rawSoftwareMultipart("POST", "/api/latest/fleet/software/package", contentB, map[string]string{"self_service": "true"}) + var addResp uploadSoftwareInstallerResponse + require.NoError(t, json.Unmarshal(postBody, &addResp)) + require.NotNil(t, addResp.SoftwarePackage) + require.Equal(t, hashOf(contentB), addResp.SoftwarePackage.StorageID, "POST echoes the just-added package, not first-added") + + // Adding a second package emits the same added_software activity. + s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), ``, 0) + + // Re-uploading identical bytes is rejected (per-title hash dedupe). + upload(contentA, false, http.StatusConflict, "already added (same SHA-256 hash)") + + var titleID uint + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &titleID, + `SELECT DISTINCT title_id FROM software_installers WHERE global_or_team_id = ? AND filename = ?`, team.ID, "deploy.sh") + }) + require.NotZero(t, titleID) + + getTitle := func() *fleet.SoftwareTitle { + var resp getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), getSoftwareTitleRequest{}, + http.StatusOK, &resp, "team_id", fmt.Sprintf("%d", team.ID)) + return resp.SoftwareTitle + } + + // --- Detail endpoint: full packages[] shape, software_package == first-added --- + title := getTitle() + require.Len(t, title.Packages, 2) + require.NotNil(t, title.SoftwarePackage) + require.Equal(t, title.Packages[0].InstallerID, title.SoftwarePackage.InstallerID) + require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) + require.Equal(t, hashOf(contentB), title.Packages[1].StorageID) + require.Equal(t, hashOf(contentA), title.SoftwarePackage.StorageID) + // per-package fields are independent + require.False(t, title.Packages[0].SelfService) + require.True(t, title.Packages[1].SelfService) + // scripts are hydrated (for a script package the install script is the file) + require.Equal(t, contentA, title.Packages[0].InstallScript) + require.Equal(t, contentB, title.Packages[1].InstallScript) + + installerA := title.Packages[0].InstallerID + installerB := title.Packages[1].InstallerID + + // --- List endpoint: trimmed packages[] --- + var listResp listSoftwareTitlesResponse + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &listResp, + "query", "deploy", "team_id", fmt.Sprintf("%d", team.ID)) + require.Len(t, listResp.SoftwareTitles, 1) + lt := listResp.SoftwareTitles[0] + require.Len(t, lt.Packages, 2) + require.NotNil(t, lt.SoftwarePackage) + require.Equal(t, "deploy.sh", lt.SoftwarePackage.Name) + require.Equal(t, "deploy.sh", lt.Packages[0].Name) + require.NotNil(t, lt.Packages[0].SelfService) + require.False(t, *lt.Packages[0].SelfService) + require.NotNil(t, lt.Packages[1].SelfService) + require.True(t, *lt.Packages[1].SelfService) + + // The list packages[] must omit the host-only last_install/last_uninstall fields. + listRes := s.Do("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, + "query", "deploy", "team_id", fmt.Sprintf("%d", team.ID)) + rawList, err := io.ReadAll(listRes.Body) + require.NoError(t, err) + listRes.Body.Close() + var rawListParsed struct { + SoftwareTitles []struct { + Packages []map[string]json.RawMessage `json:"packages"` + } `json:"software_titles"` + } + require.NoError(t, json.Unmarshal(rawList, &rawListParsed)) + require.Len(t, rawListParsed.SoftwareTitles, 1) + require.Len(t, rawListParsed.SoftwareTitles[0].Packages, 2) + for _, p := range rawListParsed.SoftwareTitles[0].Packages { + _, hasLastInstall := p["last_install"] + _, hasLastUninstall := p["last_uninstall"] + require.False(t, hasLastInstall, "list packages[] must omit last_install") + require.False(t, hasLastUninstall, "list packages[] must omit last_uninstall") + } + + // --- Edit without installer_id on a multi-package title -> 400 --- + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + TeamID: &team.ID, + SelfService: new(true), + }, http.StatusBadRequest, "installer_id is required") + + // --- Edit B's file to A's content (sibling hash collision) -> 409, both unchanged --- + collideFile, err := fleet.NewTempFileReader(strings.NewReader(contentA), func() string { return t.TempDir() }) + require.NoError(t, err) + defer collideFile.Close() + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + InstallerID: installerB, + TeamID: &team.ID, + Filename: "deploy.sh", + InstallerFile: collideFile, + }, http.StatusConflict, "already added (same SHA-256 hash)") + + title = getTitle() + require.Len(t, title.Packages, 2) + require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) + require.Equal(t, hashOf(contentB), title.Packages[1].StorageID) + + // --- Edit B's file to a new hash -> ok; A untouched; PATCH echoes the edited package --- + patchBody := rawSoftwareMultipart("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package", titleID), + contentC, map[string]string{"installer_id": fmt.Sprintf("%d", installerB)}) + var patchResp getSoftwareInstallerResponse + require.NoError(t, json.Unmarshal(patchBody, &patchResp)) + require.NotNil(t, patchResp.SoftwareInstaller) + require.Equal(t, installerB, patchResp.SoftwareInstaller.InstallerID, "PATCH echoes the edited package, not first-added") + require.Equal(t, hashOf(contentC), patchResp.SoftwareInstaller.StorageID) + title = getTitle() + require.Equal(t, hashOf(contentA), title.Packages[0].StorageID) + require.Equal(t, hashOf(contentC), title.Packages[1].StorageID) + + // --- Re-save B's current file -> no-op (200) --- + sameFile, err := fleet.NewTempFileReader(strings.NewReader(contentC), func() string { return t.TempDir() }) + require.NoError(t, err) + defer sameFile.Close() + s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ + TitleID: titleID, + InstallerID: installerB, + TeamID: &team.ID, + Filename: "deploy.sh", + InstallerFile: sameFile, + }, http.StatusOK, "") + title = getTitle() + require.Len(t, title.Packages, 2) + require.Equal(t, hashOf(contentC), title.Packages[1].StorageID) + + // --- Delete one package (A) -> 204, B remains and becomes first-added --- + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, + http.StatusNoContent, "team_id", fmt.Sprintf("%d", team.ID), "installer_id", fmt.Sprintf("%d", installerA)) + title = getTitle() + require.Len(t, title.Packages, 1) + require.Equal(t, installerB, title.Packages[0].InstallerID) + require.Equal(t, installerB, title.SoftwarePackage.InstallerID) + + // --- Delete all remaining (no installer_id) -> 204, no packages left --- + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, + http.StatusNoContent, "team_id", fmt.Sprintf("%d", team.ID)) + var remaining int + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &remaining, + `SELECT COUNT(*) FROM software_installers WHERE global_or_team_id = ? AND title_id = ?`, team.ID, titleID) + }) + require.Zero(t, remaining, "title-level delete removes all packages") +} + // 1. host reports software // 2. reconciler runs, creates title // 3. installer is uploaded, creates a new software title @@ -27972,7 +28185,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 +28308,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{ diff --git a/server/service/software_installers.go b/server/service/software_installers.go index ed85e4ffa79..feb5a7a3332 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -42,7 +42,9 @@ type uploadSoftwareInstallerRequest struct { } type updateSoftwareInstallerRequest struct { - TitleID uint `url:"id"` + TitleID uint `url:"id"` + // InstallerID selects which package to edit; required when the title has multiple. + InstallerID *uint File *multipart.FileHeader TeamID *uint InstallScript *string @@ -122,6 +124,14 @@ func (updateSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.TeamID = ptr.Uint(uint(fleetID)) } + if idVal, ok := r.MultipartForm.Value["installer_id"]; ok && len(idVal) > 0 && idVal[0] != "" { + installerID, err := strconv.ParseUint(idVal[0], 10, 32) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Invalid installer_id: %s", idVal[0])} + } + decoded.InstallerID = new(uint(installerID)) + } + installScriptMultipart, ok := r.MultipartForm.Value["install_script"] if ok && len(installScriptMultipart) > 0 { decoded.InstallScript = &installScriptMultipart[0] @@ -254,6 +264,7 @@ func updateSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s payload := &fleet.UpdateSoftwareInstallerPayload{ TitleID: req.TitleID, + InstallerID: ptr.ValOrZero(req.InstallerID), TeamID: req.TeamID, InstallScript: req.InstallScript, PreInstallQuery: req.PreInstallQuery, @@ -500,8 +511,10 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. } type deleteSoftwareInstallerRequest struct { - TeamID *uint `query:"team_id" renameto:"fleet_id"` - TitleID uint `url:"title_id"` + TeamID *uint `query:"team_id" renameto:"fleet_id"` + // InstallerID deletes one package; omitted deletes all of the title's packages. + InstallerID *uint `query:"installer_id,optional"` + TitleID uint `url:"title_id"` } type deleteSoftwareInstallerResponse struct { @@ -513,14 +526,14 @@ func (r deleteSoftwareInstallerResponse) Status() int { return http.StatusNoCon func deleteSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*deleteSoftwareInstallerRequest) - err := svc.DeleteSoftwareInstaller(ctx, req.TitleID, req.TeamID) + err := svc.DeleteSoftwareInstaller(ctx, req.TitleID, req.TeamID, req.InstallerID) if err != nil { return deleteSoftwareInstallerResponse{Err: err}, nil } return deleteSoftwareInstallerResponse{}, nil } -func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint) error { +func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, teamID *uint, installerID *uint) error { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) diff --git a/server/service/software_installers_test.go b/server/service/software_installers_test.go index 26a87d5374b..5e6a3f887e4 100644 --- a/server/service/software_installers_test.go +++ b/server/service/software_installers_test.go @@ -98,6 +98,9 @@ func TestSoftwareInstallersAuth(t *testing.T) { ds.GetInHouseAppMetadataByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) (*fleet.SoftwareInstaller, error) { return &fleet.SoftwareInstaller{TeamID: tt.teamID}, nil } + ds.GetSoftwarePackagesByTeamAndTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + return []*fleet.SoftwareInstaller{{TeamID: tt.teamID, InstallerID: 1}}, nil + } ds.DeleteSoftwareInstallerFunc = func(ctx context.Context, installerID uint) error { return nil @@ -145,7 +148,7 @@ func TestSoftwareInstallersAuth(t *testing.T) { checkAuthErr(t, tt.shouldFailRead, err) } - err = svc.DeleteSoftwareInstaller(ctx, 1, tt.teamID) + err = svc.DeleteSoftwareInstaller(ctx, 1, tt.teamID, nil) if tt.teamID == nil { require.Error(t, err) } else { diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 4b5cbafebc6..c3f47a5153f 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -192,40 +192,72 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint if license.IsPremium() { // add software installer data if needed if software.SoftwareInstallersCount > 0 { - meta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true) + pkgs, err := svc.ds.GetSoftwarePackagesByTeamAndTitleID(ctx, teamID, id) if err != nil && !fleet.IsNotFound(err) { - return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") + return nil, ctxerr.Wrap(ctx, err, "get software packages") } - if meta != nil { - summary, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, meta.InstallerID) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get software installer status summary") + if len(pkgs) > 0 { + // Display name, icon, and policies are title-level; fetch once from the first-added package. + titleMeta, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, teamID, id, true) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get software installer metadata") } - meta.Status = summary - } - software.SoftwarePackage = meta - // Populate FleetMaintainedVersions if this is an FMA - if meta != nil && meta.FleetMaintainedAppID != nil { - fmaVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, id, false) + // Categories are per-package. + installerIDs := make([]uint, len(pkgs)) + for i, pkg := range pkgs { + installerIDs[i] = pkg.InstallerID + } + categoriesByInstaller, err := svc.ds.GetCategoriesForSoftwareInstallers(ctx, installerIDs) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions") + return nil, ctxerr.Wrap(ctx, err, "get categories for software packages") } - meta.FleetMaintainedVersions = fmaVersions - // No pin row means the title tracks "Latest" (nil pinned_version); any other error is real. - pinnedVersion, err := svc.ds.GetPinnedVersion(ctx, teamID, id) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, ctxerr.Wrap(ctx, err, "get pinned version") + for _, pkg := range pkgs { + summary, err := svc.ds.GetSummaryHostSoftwareInstalls(ctx, pkg.InstallerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installer status summary") + } + pkg.Status = summary + pkg.Categories = categoriesByInstaller[pkg.InstallerID] + + if titleMeta != nil { + pkg.DisplayName = titleMeta.DisplayName + pkg.IconUrl = titleMeta.IconUrl + // Automatic install policies are title-level for now. + pkg.AutomaticInstallPolicies = titleMeta.AutomaticInstallPolicies + } + + // Populate FleetMaintainedVersions/pin/patch policy for FMA titles. + // An FMA title has a single active package, so this runs on it. + if pkg.FleetMaintainedAppID != nil { + fmaVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, id, false) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions") + } + pkg.FleetMaintainedVersions = fmaVersions + + // No pin row means the title tracks "Latest" (nil pinned_version); any other error is real. + pinnedVersion, err := svc.ds.GetPinnedVersion(ctx, teamID, id) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, err, "get pinned version") + } + pkg.PinnedVersion = pinnedVersion + + patchPolicy, err := svc.ds.GetPatchPolicy(ctx, teamID, id) + if err != nil && !fleet.IsNotFound(err) { + return nil, ctxerr.Wrap(ctx, err, "get patch policy") + } + pkg.PatchPolicy = patchPolicy + } } - meta.PinnedVersion = pinnedVersion - // Populate PatchPolicy if there is one - patchPolicy, err := svc.ds.GetPatchPolicy(ctx, teamID, id) - if err != nil && !fleet.IsNotFound(err) { - return nil, ctxerr.Wrap(ctx, err, "get patch policy") + // software_package is kept for backwards compatibility and equals the first-added package. + software.Packages = make([]fleet.SoftwareInstaller, len(pkgs)) + for i, pkg := range pkgs { + software.Packages[i] = *pkg } - meta.PatchPolicy = patchPolicy + software.SoftwarePackage = pkgs[0] } } diff --git a/server/service/testing_client_test.go b/server/service/testing_client_test.go index 5f1770b09e2..20413129013 100644 --- a/server/service/testing_client_test.go +++ b/server/service/testing_client_test.go @@ -956,6 +956,9 @@ func (ts *withServer) updateSoftwareInstaller( tmID = *payload.TeamID } require.NoError(t, w.WriteField("team_id", fmt.Sprintf("%d", tmID))) + if payload.InstallerID != 0 { + require.NoError(t, w.WriteField("installer_id", fmt.Sprintf("%d", payload.InstallerID))) + } // add the remaining fields if payload.InstallScript != nil { require.NoError(t, w.WriteField("install_script", *payload.InstallScript))