From e2ab4235ab632479bd69fb9e12751bd912b2afa8 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 13:18:03 -0400 Subject: [PATCH 01/10] Add regime-aware uniqueness for multiple packages per title (#48396) Replace the version-unique key on software_installers with a regime-aware unique key. A new VIRTUAL dedup_token column resolves to storage_id for custom packages (dedupe by content hash, so Arm and Intel builds of one version coexist while identical bytes are rejected) and to version for FMA packages (version-uniqueness unchanged, so the auto-update cron can cache several versions backed by the same bytes). The migration keeps the first-added row per (global_or_team_id, title_id, dedup_token), re-points policies off the removed rows, and deletes the rest before adding UNIQUE KEY idx_software_installers_dedup. --- ...29163945_MultipleCustomPackagesPerTitle.go | 76 +++++++++ ...945_MultipleCustomPackagesPerTitle_test.go | 156 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go create mode 100644 server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go new file mode 100644 index 00000000000..a775973f723 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -0,0 +1,76 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20260629163945, Down_20260629163945) +} + +func Up_20260629163945(tx *sql.Tx) error { + // A title may now have more than one package. dedup_token makes uniqueness + // regime-aware: custom rows resolve it to storage_id so they dedupe by content hash + // (Arm and Intel of the same version coexist, identical bytes are rejected), while + // FMA rows resolve it to version so version-uniqueness is unchanged and the same + // bytes can back several versions. A (team, title) is single-regime, and a hash + // never equals a version string, so the two token spaces don't collide. The column + // is VIRTUAL so the add is in-place; its only consumer is the unique key below. + if _, err := tx.Exec(` + ALTER TABLE software_installers + ADD COLUMN dedup_token VARCHAR(255) + GENERATED ALWAYS AS (IF(fleet_maintained_app_id IS NULL, storage_id, version)) VIRTUAL + `); err != nil { + return fmt.Errorf("adding dedup_token column: %w", err) + } + + // Where a (global_or_team_id, title_id, dedup_token) has more than one row, keep the + // first-added (smallest id) as the survivor and delete the rest, so the unique key + // below can be added. This collapses the duplicate-active custom rows from the P1 bug + // and any custom same-hash duplicates. FMA rows already satisfy version-uniqueness. + // Re-point policies off the deleted rows first, since policies.software_installer_id + // is RESTRICT. + const dupGroups = ` + SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id + FROM software_installers + WHERE title_id IS NOT NULL + GROUP BY global_or_team_id, title_id, dedup_token + HAVING COUNT(*) > 1` + + if _, err := tx.Exec(fmt.Sprintf(` + UPDATE policies p + JOIN software_installers si ON si.id = p.software_installer_id + JOIN (%s) dup + ON si.global_or_team_id = dup.global_or_team_id + AND si.title_id = dup.title_id + AND si.dedup_token = dup.dedup_token + SET p.software_installer_id = dup.keep_id + WHERE si.id <> dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("re-pointing policies off duplicate installers: %w", err) + } + + if _, err := tx.Exec(fmt.Sprintf(` + DELETE si FROM software_installers si + JOIN (%s) dup + ON si.global_or_team_id = dup.global_or_team_id + AND si.title_id = dup.title_id + AND si.dedup_token = dup.dedup_token + WHERE si.id <> dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("deleting duplicate installers: %w", err) + } + + if _, err := tx.Exec(` + ALTER TABLE software_installers + DROP INDEX idx_software_installers_team_title_version, + ADD UNIQUE KEY idx_software_installers_dedup (global_or_team_id, title_id, dedup_token) + `); err != nil { + return fmt.Errorf("swapping software_installers unique key: %w", err) + } + + return nil +} + +func Down_20260629163945(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go new file mode 100644 index 00000000000..5b42c76c04d --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -0,0 +1,156 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20260629163945(t *testing.T) { + db := applyUpToPrev(t) + + insertTitle := func(name string, source string) int64 { + return execNoErrLastID(t, db, `INSERT INTO software_titles (name, source) VALUES (?, ?)`, name, source) + } + + const installerInsert = ` + INSERT INTO software_installers + (team_id, global_or_team_id, title_id, filename, extension, version, platform, + install_script_content_id, uninstall_script_content_id, storage_id, package_ids, patch_query, + fleet_maintained_app_id, is_active) + VALUES (?, ?, ?, ?, 'pkg', ?, ?, ?, ?, ?, '', '', ?, ?)` + + insertScript := func(seed string) int64 { + return execNoErrLastID(t, db, `INSERT INTO script_contents (contents, md5_checksum) VALUES ('#!/bin/sh', UNHEX(MD5(?)))`, seed) + } + + // teamID nil means no-team (global_or_team_id 0); fmaID nil means a custom package. + args := func(titleID int64, teamID *int64, platform string, version string, storage string, fmaID *int64, active int) []any { + script := insertScript(storage + version) + var globalOrTeamID int64 + if teamID != nil { + globalOrTeamID = *teamID + } + return []any{teamID, globalOrTeamID, titleID, storage + "-" + version + ".pkg", version, platform, script, script, storage, fmaID, active} + } + insertInstaller := func(titleID int64, teamID *int64, platform string, version string, storage string, fmaID *int64, active int) int64 { + return execNoErrLastID(t, db, installerInsert, args(titleID, teamID, platform, version, storage, fmaID, active)...) + } + tryInsertInstaller := func(titleID int64, teamID *int64, platform string, version string, storage string, fmaID *int64, active int) error { + _, err := db.Exec(installerInsert, args(titleID, teamID, platform, version, storage, fmaID, active)...) + return err + } + + countRows := func(query string, qargs ...any) int { + var n int + require.NoError(t, db.QueryRow(query, qargs...).Scan(&n)) + return n + } + remainingIDs := func(titleID int64) []int64 { + var ids []int64 + r, err := db.Query(`SELECT id FROM software_installers WHERE title_id = ? ORDER BY id`, titleID) + require.NoError(t, err) + for r.Next() { + var id int64 + require.NoError(t, r.Scan(&id)) + ids = append(ids, id) + } + require.NoError(t, r.Err()) + require.NoError(t, r.Close()) + return ids + } + + team := execNoErrLastID(t, db, `INSERT INTO teams (name) VALUES ('Team 1')`) + label := execNoErrLastID(t, db, `INSERT INTO labels (name, query) VALUES ('L1', '')`) + category := execNoErrLastID(t, db, `INSERT INTO software_categories (name) VALUES ('C1')`) + fma := execNoErrLastID(t, db, `INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier) VALUES ('AppD', 'appd/darwin', 'darwin', 'com.appd')`) + + // Single custom package (no-team). Untouched. + titleA := insertTitle("AppA", "apps") + soloID := insertInstaller(titleA, nil, "darwin", "1.0", "hash-a", nil, 1) + + // Custom hash-duplicate (no-team): two active rows with the same content but + // different versions, so the still-present version key allows seeding both. Each + // carries a label and a category, and a policy points at the row to be deleted. + titleB := insertTitle("AppB", "programs") + keepB := insertInstaller(titleB, nil, "windows", "1.0", "hash-b", nil, 1) + dupB := insertInstaller(titleB, nil, "windows", "2.0", "hash-b", nil, 1) + for _, id := range []int64{keepB, dupB} { + execNoErr(t, db, `INSERT INTO software_installer_labels (software_installer_id, label_id) VALUES (?, ?)`, id, label) + execNoErr(t, db, `INSERT INTO software_installer_software_categories (software_installer_id, software_category_id) VALUES (?, ?)`, id, category) + } + policyID := execNoErrLastID(t, db, ` + INSERT INTO policies (name, query, description, checksum, software_installer_id) + VALUES ('p1', 'SELECT 1', '', UNHEX(MD5('p1')), ?)`, dupB) + + // Custom hash-duplicate scoped to a team, different source. + titleC := insertTitle("AppC", "rpm_packages") + keepC := insertInstaller(titleC, &team, "linux", "1.0", "hash-c", nil, 1) + dupC := insertInstaller(titleC, &team, "linux", "1.1", "hash-c", nil, 1) + + // FMA with the same bytes backing two versions. Tokens resolve to version, so both + // must survive. + titleD := insertTitle("AppD", "apps") + fmaOld := insertInstaller(titleD, nil, "darwin", "1.0", "hash-d", &fma, 0) + fmaActive := insertInstaller(titleD, nil, "darwin", "2.0", "hash-d", &fma, 1) + + applyNext(t, db) + + // The version key is gone, replaced by the regime-aware dedup key. + require.Zero(t, countRows(` + SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'software_installers' + AND index_name = 'idx_software_installers_team_title_version'`)) + var dedupCols []string + rows, err := db.Query(` + SELECT column_name FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'software_installers' + AND index_name = 'idx_software_installers_dedup' + ORDER BY seq_in_index`) + require.NoError(t, err) + for rows.Next() { + var col string + require.NoError(t, rows.Scan(&col)) + dedupCols = append(dedupCols, col) + } + require.NoError(t, rows.Err()) + require.NoError(t, rows.Close()) + require.Equal(t, []string{"global_or_team_id", "title_id", "dedup_token"}, dedupCols) + + // Single-package title untouched. + require.Equal(t, []int64{soloID}, remainingIDs(titleA)) + + // Custom hash-duplicates collapse to the first-added row, which stays active. + require.Equal(t, []int64{keepB}, remainingIDs(titleB)) + require.Equal(t, []int64{keepC}, remainingIDs(titleC)) + require.NotContains(t, remainingIDs(titleC), dupC) + require.Equal(t, 1, countRows(`SELECT is_active FROM software_installers WHERE id = ?`, keepB)) + require.Equal(t, 1, countRows(`SELECT is_active FROM software_installers WHERE id = ?`, keepC)) + + // The survivor keeps its label and category; the deleted row's cascade away. + require.Equal(t, 1, countRows(`SELECT COUNT(*) FROM software_installer_labels WHERE software_installer_id = ?`, keepB)) + require.Equal(t, 1, countRows(`SELECT COUNT(*) FROM software_installer_software_categories WHERE software_installer_id = ?`, keepB)) + require.Zero(t, countRows(`SELECT COUNT(*) FROM software_installer_labels WHERE software_installer_id = ?`, dupB)) + require.Zero(t, countRows(`SELECT COUNT(*) FROM software_installer_software_categories WHERE software_installer_id = ?`, dupB)) + + // The policy was re-pointed off the deleted row onto the survivor. + var repointed int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM policies WHERE id = ?`, policyID).Scan(&repointed)) + require.Equal(t, keepB, repointed) + + // FMA same-hash-different-version rows both survive. + require.Equal(t, []int64{fmaOld, fmaActive}, remainingIDs(titleD)) + + // New key behavior. Custom same-version-different-hash (Arm vs Intel) is accepted; + // these could not be seeded before the migration because the old version key blocked + // two rows sharing a version. + titleE := insertTitle("AppE", "apps") + require.NoError(t, tryInsertInstaller(titleE, nil, "darwin", "9.0", "hash-e1", nil, 1)) + require.NoError(t, tryInsertInstaller(titleE, nil, "darwin", "9.0", "hash-e2", nil, 1)) + + // A second package with identical bytes on the same title is rejected by the key. + require.Error(t, tryInsertInstaller(titleE, nil, "darwin", "8.0", "hash-e1", nil, 1)) + + // FMA can still cache another version backed by the same bytes. + require.NoError(t, tryInsertInstaller(titleD, nil, "darwin", "3.0", "hash-d", &fma, 0)) +} From 2b2d476d9b210ee1fc50c6fd0264e2a58e598bc0 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 16:21:11 -0400 Subject: [PATCH 02/10] Hash-based duplicate guard and 10-package limit for custom software (#48396) Allow multiple custom packages per title in MatchOrCreateSoftwareInstaller. checkSoftwareConflictsByIdentifier keeps the cross-type VPP/in-house checks and the FMA-vs-custom single-regime guard, then rejects a custom package whose dedup_token (content hash) already backs the title and enforces a 10-package limit. The conflict copy now distinguishes VPP apps, Fleet-maintained apps, and software packages. The cross-type and FMA checks run before getOrGenerateSoftwareInstallerTitleID so a rejected upload never triggers its title-create/rename side effects. --- server/datastore/mysql/in_house_apps.go | 22 -- server/datastore/mysql/software_installers.go | 204 +++++++++++------- .../mysql/software_installers_test.go | 148 ++++++++++--- server/fleet/errors.go | 5 + server/service/integration_enterprise_test.go | 16 +- .../service/integration_vpp_install_test.go | 4 +- 6 files changed, 267 insertions(+), 132 deletions(-) diff --git a/server/datastore/mysql/in_house_apps.go b/server/datastore/mysql/in_house_apps.go index fdc77ba8098..7fb5c1e5439 100644 --- a/server/datastore/mysql/in_house_apps.go +++ b/server/datastore/mysql/in_house_apps.go @@ -1653,28 +1653,6 @@ WHERE return exists == 1, nil } -func (ds *Datastore) checkInstallerExistsByName(ctx context.Context, q sqlx.QueryerContext, teamID *uint, name, source, platform string) (bool, error) { - const stmt = ` -SELECT 1 -FROM - software_titles st - INNER JOIN software_installers ON st.id = software_installers.title_id - AND software_installers.global_or_team_id = ? -WHERE - st.name = ? - AND st.source = ? - AND st.extension_for = '' - AND software_installers.platform = ? -` - - var exists int - err := sqlx.GetContext(ctx, q, &exists, stmt, ptr.ValOrZero(teamID), name, source, platform) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return false, ctxerr.Wrap(ctx, err, "check installer exists by name") - } - return exists == 1, nil -} - func (ds *Datastore) checkInHouseAppExistsForAdamID(ctx context.Context, q sqlx.QueryerContext, teamID *uint, appID fleet.VPPAppID) (exists bool, title string, err error) { const stmt = ` SELECT st.name diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 06ad664d41c..b03ee212d93 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -198,16 +198,8 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, errors.New("validated labels must not be nil") } - err = ds.checkSoftwareConflictsByIdentifier(ctx, payload) - if err != nil { - teamName, err := ds.getTeamName(ctx, payload.TeamID) - if err != nil { - return 0, 0, ctxerr.Wrap(ctx, err, "get team for installer conflict error") - } - - return 0, 0, ctxerr.Wrap(ctx, fleet.ConflictError{ - Message: fmt.Sprintf(fleet.CantAddSoftwareConflictMessage, payload.Title, teamName), - }, "vpp app conflicts with existing software installer") + if err := ds.checkSoftwareConflictsByIdentifier(ctx, payload); err != nil { + return 0, 0, err } // Insert in house app instead of software installer @@ -237,37 +229,6 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID") } - // Enforce team-scoped uniqueness by storage hash, aligning upload behavior with GitOps. - // However, if the duplicate-by-hash is for the same title/source on the same team, - // let the DB unique (team,title) constraint surface the conflict (so tests expecting - // a 409 Conflict with "already exists" still pass). - // Only validate for script packages (.sh/.ps1) where content hash equals functionality. - // Binary installers can legitimately share content with different install scripts. - if payload.StorageID != "" && fleet.IsScriptPackage(payload.Extension) { - var tmID uint - if payload.TeamID != nil { - tmID = *payload.TeamID - } - // Check duplicates by content hash only (ignore URL) to align with GitOps/apply rules. - teamsByHash, err := ds.GetTeamsWithInstallerByHash(ctx, payload.StorageID, "") - if err != nil { - return 0, 0, ctxerr.Wrap(ctx, err, "check duplicate installer by hash") - } - if found, exists := teamsByHash[tmID]; exists { - // If the existing installer has the same title and source, allow the insert to proceed - // so that the existing UNIQUE (global_or_team_id, title_id) constraint yields a - // Conflict error with the expected message. - // Since this is not an in-house app, only one installer per team can exist. - if !(found[0].Title == payload.Title && found[0].Source == payload.Source) { - return 0, 0, fleet.NewInvalidArgumentError( - "software", - "Couldn't add software. An installer with identical contents already exists on this fleet.", - ) - } - // If exact duplicate (same title and source), continue to let DB constraint handle it - } - } - if err := ds.addSoftwareTitleToMatchingSoftware(ctx, titleID, payload); err != nil { return 0, 0, ctxerr.Wrap(ctx, err, "add software title to matching software") } @@ -511,27 +472,47 @@ func getAvailablePolicyName(ctx context.Context, db sqlx.QueryerContext, teamID return availableName, nil } +func softwareInstallerTitleSelect(payload *fleet.UploadSoftwareInstallerPayload) (string, []any) { + switch { + case payload.BundleIdentifier != "": + // match by bundle identifier and source first, or standard matching if we don't have a bundle identifier match + return `SELECT id FROM software_titles WHERE (bundle_identifier = ? AND source = ?) OR (name = ? AND source = ? AND extension_for = '') ORDER BY bundle_identifier = ? DESC LIMIT 1`, + []any{payload.BundleIdentifier, payload.Source, payload.Title, payload.Source, payload.BundleIdentifier} + case payload.Source == "programs" && payload.UpgradeCode != "": + // select by either name or upgrade code, preferring upgrade code + return `SELECT id FROM software_titles WHERE (name = ? AND source = ? AND extension_for = '' AND upgrade_code = '') OR upgrade_code = ? ORDER BY upgrade_code = ? DESC LIMIT 1`, + []any{payload.Title, payload.Source, payload.UpgradeCode, payload.UpgradeCode} + default: + return `SELECT id FROM software_titles WHERE name = ? AND source = ? AND extension_for = ''`, + []any{payload.Title, payload.Source} + } +} + +func (ds *Datastore) getExistingSoftwareInstallerTitleID(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { + stmt, args := softwareInstallerTitleSelect(payload) + var titleID uint + switch err := sqlx.GetContext(ctx, ds.reader(ctx), &titleID, stmt, args...); { + case err == nil: + return titleID, nil + case errors.Is(err, sql.ErrNoRows): + return 0, notFound("SoftwareTitle") + default: + return 0, ctxerr.Wrap(ctx, err, "get existing software installer title id") + } +} + func (ds *Datastore) getOrGenerateSoftwareInstallerTitleID(ctx context.Context, tx sqlx.ExtContext, payload *fleet.UploadSoftwareInstallerPayload) (uint, error) { - selectStmt := `SELECT id FROM software_titles WHERE name = ? AND source = ? AND extension_for = ''` - selectArgs := []any{payload.Title, payload.Source} + selectStmt, selectArgs := softwareInstallerTitleSelect(payload) insertStmt := `INSERT INTO software_titles (name, source, extension_for) VALUES (?, ?, '')` insertArgs := []any{payload.Title, payload.Source} // upgrade_code should be set to NULL for non-Windows software, empty or non-empty string for Windows software if payload.Source == "programs" { - // select by either name or upgrade code, preferring upgrade code - if payload.UpgradeCode != "" { - selectStmt = `SELECT id FROM software_titles WHERE (name = ? AND source = ? AND extension_for = '' AND upgrade_code = '') OR upgrade_code = ? ORDER BY upgrade_code = ? DESC LIMIT 1` - selectArgs = []any{payload.Title, payload.Source, payload.UpgradeCode, payload.UpgradeCode} - } insertStmt = `INSERT INTO software_titles (name, source, extension_for, upgrade_code) VALUES (?, ?, '', ?)` insertArgs = []any{payload.Title, payload.Source, payload.UpgradeCode} } if payload.BundleIdentifier != "" { - // match by bundle identifier and source first, or standard matching if we don't have a bundle identifier match - selectStmt = `SELECT id FROM software_titles WHERE (bundle_identifier = ? AND source = ?) OR (name = ? AND source = ? AND extension_for = '') ORDER BY bundle_identifier = ? DESC LIMIT 1` - selectArgs = []any{payload.BundleIdentifier, payload.Source, payload.Title, payload.Source, payload.BundleIdentifier} insertStmt = `INSERT INTO software_titles (name, source, bundle_identifier, extension_for) VALUES (?, ?, ?, '')` insertArgs = []any{payload.Title, payload.Source, payload.BundleIdentifier} } @@ -4052,7 +4033,19 @@ LIMIT 1` return &installer, nil } +const maxPackagesPerTitle = 10 + func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error { + conflict := func(message string) error { + teamName, err := ds.getTeamName(ctx, payload.TeamID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get team for installer conflict error") + } + return ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(message, payload.Title, teamName), + }, "software conflicts with existing software on the title") + } + switch payload.Platform { // currently, the platform will always be ios for .ipa files case string(fleet.IOSPlatform), string(fleet.IPadOSPlatform): @@ -4067,7 +4060,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return ctxerr.Wrap(ctx, err, "check if VPP app exists for title identifier") } if exists { - return alreadyExists("VPP app", payload.Title) + return conflict(fleet.SoftwareAlreadyHasVPPAppMessage) } // check if equivalent installers exist, duplicate in-house apps are checked in insertInHouseApp @@ -4076,7 +4069,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return ctxerr.Wrap(ctx, err, "check if software installer exists for title identifier") } if exists { - return alreadyExists("software installer", payload.Title) + return conflict(fleet.SoftwareAlreadyHasPackageMessage) } } case string(fleet.MacOSPlatform): @@ -4085,41 +4078,104 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return ctxerr.Wrap(ctx, err, "check if VPP app exists for title identifier") } if exists { - return alreadyExists("VPP app", payload.Title) + return conflict(fleet.SoftwareAlreadyHasVPPAppMessage) } + } - // check only for installers, since in-house apps target iOS/iPadOS so they won't conflict - exists, err = ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.BundleIdentifier, payload.Platform, softwareTypeInstaller) - if err != nil { - return ctxerr.Wrap(ctx, err, "check if installer exists for title identifier") + // custom packages and Fleet-maintained apps can't share a title + mixed, err := ds.checkFleetMaintainedAppExists(ctx, payload) + if err != nil { + return err + } + if mixed { + if payload.FleetMaintainedAppID != nil { + return conflict(fleet.SoftwareAlreadyHasPackageMessage) } - if exists { - return alreadyExists("installer", payload.Title) + return conflict(fleet.SoftwareAlreadyHasFleetMaintainedAppMessage) + } + + if payload.FleetMaintainedAppID == nil { + titleID, err := ds.getExistingSoftwareInstallerTitleID(ctx, payload) + if fleet.IsNotFound(err) { + return nil } - case "windows", "linux": - // check by name before any software title renaming side effects can happen - exists, err := ds.checkInstallerExistsByName(ctx, ds.reader(ctx), payload.TeamID, payload.Title, payload.Source, payload.Platform) if err != nil { - return ctxerr.Wrap(ctx, err, "check if installer exists by name") + return err } - if exists { - return alreadyExists("installer", payload.Title) + + // check if a custom package with the same hash exists + var dup bool + err = sqlx.GetContext(ctx, ds.reader(ctx), &dup, ` + SELECT EXISTS ( + SELECT 1 FROM software_installers + WHERE global_or_team_id = ? AND title_id = ? AND dedup_token = ? + )`, ptr.ValOrZero(payload.TeamID), titleID, payload.StorageID) + if err != nil { + return ctxerr.Wrap(ctx, err, "check duplicate package by hash") + } + if dup { + return ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageHashConflictMessage, payload.Filename), + }, "duplicate package by hash") } - if payload.UpgradeCode != "" { - exists, err := ds.checkInstallerOrInHouseAppExists(ctx, ds.reader(ctx), payload.TeamID, payload.UpgradeCode, payload.Platform, softwareTypeInstaller) - if err != nil { - return ctxerr.Wrap(ctx, err, "check if installer exists for upgrade code") - } - if exists { - return alreadyExists("installer", payload.Title) - } + // a title holds at most maxPackagesPerTitle custom packages + var count int + err = sqlx.GetContext(ctx, ds.reader(ctx), &count, ` + SELECT COUNT(*) FROM software_installers + WHERE global_or_team_id = ? AND title_id = ?`, ptr.ValOrZero(payload.TeamID), titleID) + if err != nil { + return ctxerr.Wrap(ctx, err, "count packages on the title") + } + if count >= maxPackagesPerTitle { + return ctxerr.Wrap(ctx, fleet.ConflictError{ + Message: fmt.Sprintf(fleet.SoftwarePackageLimitMessage, payload.Title, maxPackagesPerTitle), + }, "package limit reached") } } return nil } +func (ds *Datastore) checkFleetMaintainedAppExists(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (bool, error) { + // look for the other kind of package on the title: an FMA when adding a custom + // package, a custom package when adding an FMA. Matched by bundle identifier on + // macOS and by name or upgrade code on Windows; FMAs only exist on those platforms. + wantFMA := payload.FleetMaintainedAppID == nil + var stmt string + var args []any + switch payload.Platform { + case string(fleet.MacOSPlatform): + stmt = ` + SELECT EXISTS ( + SELECT 1 + FROM software_installers si + JOIN software_titles st ON st.id = si.title_id + WHERE si.global_or_team_id = ? AND st.source = ? AND st.bundle_identifier = ? + AND (si.fleet_maintained_app_id IS NOT NULL) = ? + )` + args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.BundleIdentifier, wantFMA} + case "windows": + stmt = ` + SELECT EXISTS ( + SELECT 1 + FROM software_installers si + JOIN software_titles st ON st.id = si.title_id + WHERE si.global_or_team_id = ? AND st.source = ? AND (st.name = ? OR (? != '' AND st.upgrade_code = ?)) + AND (si.fleet_maintained_app_id IS NOT NULL) = ? + )` + args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.Title, payload.UpgradeCode, payload.UpgradeCode, wantFMA} + default: + return false, nil + } + + var exists bool + if err := sqlx.GetContext(ctx, ds.reader(ctx), &exists, stmt, args...); err != nil { + return false, ctxerr.Wrap(ctx, err, "check fleet-maintained app exists") + } + return exists, nil +} + func (ds *Datastore) GetSoftwareTitlesForInstallAll(ctx context.Context, host *fleet.Host, categoryID *uint) ([]*fleet.HostSoftwareWithInstaller, *string, error) { // get software category and check that it exists var categoryName *string diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index ced01a08d15..66f56b4fbf9 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" @@ -4477,13 +4476,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "a.sh", "title-a")) require.NoError(t, err) - // Duplicate on Team A with different name/title but same hash → reject + // Same hash under a different title on the same team → allowed (dedupe is title-scoped) _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "b.sh", "title-b")) - require.Error(t, err) - var iae *fleet.InvalidArgumentError - if !errors.As(err, &iae) { - t.Fatalf("expected InvalidArgumentError for same-team duplicate hash, got: %T: %v", err, err) - } + require.NoError(t, err) // Same hash on different team → allowed _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamB.ID, "c.sh", "title-c")) @@ -4493,13 +4488,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(nil, "global1.sh", "title-g1")) require.NoError(t, err) - // Global scope second time (duplicate hash) → reject + // Global scope, same hash under a different title → allowed _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(nil, "global2.sh", "title-g2")) - require.Error(t, err) - var iae2 *fleet.InvalidArgumentError - if !errors.As(err, &iae2) { - t.Fatalf("expected InvalidArgumentError for global duplicate hash, got: %T: %v", err, err) - } + require.NoError(t, err) // Test that binary packages (.pkg) with duplicate hash ARE allowed mkPkgPayload := func(teamID *uint, filename, title string) *fleet.UploadSoftwareInstallerPayload { @@ -4526,9 +4517,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPkgPayload(&teamA.ID, "pkg2.pkg", "title-pkg2")) require.NoError(t, err, "binary packages with same hash should be allowed on same team") - // Binary packages with same title on same team → reject + // Same title and hash on the same team → rejected by hash _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "a.sh", "title-a")) - require.ErrorContainsf(t, err, `"title-a" already exists with fleet "Team A".`, "expected existsError for same-team duplicate title, got: %T: %v", err, err) + require.ErrorContains(t, err, "same SHA-256 hash") } func testAddSoftwareTitleToMatchingSoftware(t *testing.T, ds *Datastore) { @@ -5768,7 +5759,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()}) require.NoError(t, err) - const conflictMsg = "already has an installer available for" + const conflictMsg = "already has an Apple App Store (VPP) on" // macOS installer conflicting with a VPP app on the same bundle id. test.CreateInsertGlobalVPPToken(t, ds) @@ -5826,7 +5817,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data }) require.NoError(t, err) - // macOS installer conflicting with the same installer at a newer version. + // macOS: a second version of the same title is allowed (multiple packages per title). _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "mac-base-storage", Filename: "mac-app.pkg", @@ -5855,9 +5846,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) - // Windows installer conflicting with the same Title at a newer version. + // Windows: a second version of the same title is allowed. _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "win-base-storage", Filename: "win-app.msi", @@ -5884,9 +5875,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) - // Windows installer conflicting on the upgrade code with a different Title. + // Windows: a second package matching the same upgrade code is allowed. const winUpgradeCode = "{ABCDEF12-3456-7890-ABCD-EF1234567890}" _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "win-uc-base-storage", @@ -5916,7 +5907,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) // Windows: existing installer has an upgrade code, new upload has the same // Title but no upgrade code. @@ -5947,7 +5938,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) // Reverse: existing installer has no upgrade code, new upload has the same // Title with an upgrade code. @@ -5978,9 +5969,9 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) - // Linux installer conflicting with the same Title at a newer version. + // Linux: a second version of the same title is allowed. _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: "linux-base-storage", Filename: "linux-app.deb", @@ -6007,7 +5998,112 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, conflictMsg) + require.NoError(t, err) + + // Linux .deb: a duplicate content hash on the title is rejected. + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "linux-base-storage", + Filename: "linux-app-dup.deb", + Title: "Linux App", + Extension: "deb", + Source: "deb_packages", + Platform: "linux", + Version: "3.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.ErrorContains(t, err, "same SHA-256 hash") + + // Linux .rpm: a second build is allowed, a duplicate content hash is rejected. + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "rpm-base-storage", + Filename: "linux-app.rpm", + Title: "Linux RPM App", + Extension: "rpm", + Source: "rpm_packages", + Platform: "linux", + Version: "1.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.NoError(t, err) + + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "rpm-base-storage", + Filename: "linux-app-dup.rpm", + Title: "Linux RPM App", + Extension: "rpm", + Source: "rpm_packages", + Platform: "linux", + Version: "2.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.ErrorContains(t, err, "same SHA-256 hash") + + // Same title and version but different content is allowed (e.g. Arm vs Intel builds). + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "arch-arm-storage", + Filename: "arch-app-arm.pkg", + Title: "Arch App", + BundleIdentifier: "com.example.arch", + Extension: "pkg", + Source: "apps", + Platform: "darwin", + Version: "1.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.NoError(t, err) + + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "arch-intel-storage", + Filename: "arch-app-intel.pkg", + Title: "Arch App", + BundleIdentifier: "com.example.arch", + Extension: "pkg", + Source: "apps", + Platform: "darwin", + Version: "1.0", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.NoError(t, err) + + // A title holds at most maxPackagesPerTitle packages; the next one is rejected. + for i := range maxPackagesPerTitle { + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: fmt.Sprintf("limit-storage-%d", i), + Filename: fmt.Sprintf("limit-%d.msi", i), + Title: "Limit App", + Extension: "msi", + Source: "programs", + Platform: "windows", + Version: fmt.Sprintf("1.%d", i), + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.NoError(t, err) + } + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + StorageID: "limit-storage-extra", + Filename: "limit-extra.msi", + Title: "Limit App", + Extension: "msi", + Source: "programs", + Platform: "windows", + Version: "9.9", + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + }) + require.ErrorContains(t, err, fmt.Sprintf("already has %d packages", maxPackagesPerTitle)) } func testGetSoftwareTitlesForInstallAll(t *testing.T, ds *Datastore) { diff --git a/server/fleet/errors.go b/server/fleet/errors.go index ecba68a427c..c7c87ba7af1 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -37,6 +37,11 @@ var ( CantEnablePINRequiredIfDiskEncryptionEnabled = "Couldn't enable BitLocker PIN requirement, you must enable disk encryption first." CantResendAppleDeclarationProfilesMessage = "Can't resend declaration (DDM) profiles. Unlike configuration profiles (.mobileconfig), the host automatically checks in to get the latest DDM profiles." CantAddSoftwareConflictMessage = "Couldn't add software. %s already has an installer available for the %s fleet." + SoftwarePackageHashConflictMessage = "Couldn't add. %s package is already added (same SHA-256 hash)." + SoftwareAlreadyHasVPPAppMessage = "Couldn't add. %s already has an Apple App Store (VPP) on the %s fleet." + SoftwareAlreadyHasFleetMaintainedAppMessage = "Couldn't add. %s already has a Fleet-maintained app on the %s fleet." + SoftwareAlreadyHasPackageMessage = "Couldn't add. %s already has a software package on the %s fleet." + SoftwarePackageLimitMessage = "Couldn't add. %s already has %d packages. Before adding, delete one you no longer use." ConfigProfileLabelScopingPremiumCauseMsg = "Scoping configuration profiles with labels" ) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 31160180674..1ef04fbc34b 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12989,8 +12989,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD titleID, lblA.ID, lblA.Name) s.lastActivityMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0) - // upload again fails - s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already has an installer available") + // upload again fails: identical bytes on the same title are a hash duplicate + s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "package is already added (same SHA-256 hash)") // update should succeed s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{ @@ -13197,8 +13197,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD ) s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), activityData, 0) - // upload again fails - s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already has an installer available") + // upload again fails: identical bytes on the same title are a hash duplicate + s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "package is already added (same SHA-256 hash)") // download the installer r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", *payload.TeamID)) @@ -13310,8 +13310,8 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": 0, "fleet_name": null, "fleet_id": 0, "self_service": true, "software_title_id": %d}`, titleID), 0) - // upload again fails - s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "already has an installer available") + // upload again fails: identical bytes on the same title are a hash duplicate + s.uploadSoftwareInstaller(t, payload, http.StatusConflict, "package is already added (same SHA-256 hash)") // download the installer r := s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusOK, "team_id", fmt.Sprintf("%d", 0)) @@ -27972,7 +27972,7 @@ func (s *integrationEnterpriseTestSuite) TestFMAVersionRollback() { // That logic isn't what we're trying to test here. _, _, err = s.ds.MatchOrCreateSoftwareInstaller(ctx, customPayload) assert.Error(t, err) - assert.Contains(t, err.Error(), fmt.Sprintf(fleet.CantAddSoftwareConflictMessage, customPayload.Title, team.Name)) + assert.Contains(t, err.Error(), fmt.Sprintf(fleet.SoftwareAlreadyHasFleetMaintainedAppMessage, customPayload.Title, team.Name)) // ========================================================================= // Section 2: UI single-add flow @@ -28095,7 +28095,7 @@ func (s *integrationEnterpriseTestSuite) TestFMAVersionRollback() { http.StatusConflict, ) errMsg := extractServerErrorText(conflictResp.Body) - require.Contains(t, errMsg, "already has an installer available", + require.Contains(t, errMsg, "already has a software package", "error should mention the conflict with the existing custom installer") // Confirm the FMA was NOT added — only the original custom installer exists. diff --git a/server/service/integration_vpp_install_test.go b/server/service/integration_vpp_install_test.go index e3e966bcb0a..acae1dc7127 100644 --- a/server/service/integration_vpp_install_test.go +++ b/server/service/integration_vpp_install_test.go @@ -1467,7 +1467,7 @@ func (s *integrationMDMTestSuite) TestSoftwareTitleVPPAppSoftwarePackageConflict Title: "DummyApp", TeamID: &team.ID, } - s.uploadSoftwareInstaller(t, pkgDummy, http.StatusConflict, "DummyApp already has an installer available for the Team 1 fleet.") + s.uploadSoftwareInstaller(t, pkgDummy, http.StatusConflict, "DummyApp already has an Apple App Store (VPP) on the Team 1 fleet.") // Add VPP app 2 with bundle ID com.example.noversion (conflicts with NoVersion) vppApp2 := &fleet.VPPApp{ @@ -2125,7 +2125,7 @@ func (s *integrationMDMTestSuite) TestInHouseAppVPPConflict() { s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{ Filename: "ipa_test.ipa", TeamID: &team2.ID, - }, http.StatusConflict, "already has an installer available for the IPA Conflict Team 2 fleet.") + }, http.StatusConflict, "already has an Apple App Store (VPP) on the IPA Conflict Team 2 fleet.") // Test Case 3: Verify "No team" works correctly s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{ From 529cf0f26a8cdf2746e4c0d4739acf96fb409121 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 16:44:15 -0400 Subject: [PATCH 03/10] First-added reads, list-all packages method, and multi-active regression fixes (#48396) GetSoftwareInstallerMetadataByTeamAndTitleID now returns the first-added active package. Add GetSoftwarePackagesByTeamAndTitleID returning every active package on a title with per-package label scope, for the API and precedence sub-issues. Fix the regressions that multiple active rows cause: SoftwareTitleByID counts use COUNT(DISTINCT) so the installer/VPP/in-house cross-join no longer inflates them, both ListSoftwareTitles query paths join only the first-added active installer so a title appears once (hash and package-name filters use EXISTS over all packages), and the edit path no longer rejects a title with more than one package. --- ee/server/service/software_installers.go | 5 +- ...29163945_MultipleCustomPackagesPerTitle.go | 21 ++-- ...945_MultipleCustomPackagesPerTitle_test.go | 12 +- server/datastore/mysql/software_installers.go | 119 +++++++++++++----- .../mysql/software_installers_test.go | 56 ++++++++- server/datastore/mysql/software_titles.go | 22 ++-- server/fleet/datastore.go | 4 + server/mock/datastore_mock.go | 12 ++ 8 files changed, 194 insertions(+), 57 deletions(-) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index f7ddf9c7b0b..5bc895fa443 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -411,8 +411,9 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet. return svc.updateInHouseAppInstaller(ctx, payload, vc, teamName, software) } - // TODO when we start supporting multiple installers per title X team, need to rework how we determine installer to edit - if software.SoftwareInstallersCount != 1 { + // With more than one installer on the title, this edits the first-added one. + // Choosing a specific package to edit is handled by the precedence work. + if software.SoftwareInstallersCount < 1 { return nil, &fleet.BadRequestError{ Message: "There are no software installers defined yet for this title and team. Please add an installer instead of attempting to edit.", } diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go index a775973f723..2f7eb2cad5c 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -10,13 +10,13 @@ func init() { } func Up_20260629163945(tx *sql.Tx) error { - // A title may now have more than one package. dedup_token makes uniqueness - // regime-aware: custom rows resolve it to storage_id so they dedupe by content hash - // (Arm and Intel of the same version coexist, identical bytes are rejected), while - // FMA rows resolve it to version so version-uniqueness is unchanged and the same - // bytes can back several versions. A (team, title) is single-regime, and a hash - // never equals a version string, so the two token spaces don't collide. The column - // is VIRTUAL so the add is in-place; its only consumer is the unique key below. + // A title may now have more than one package. dedup_token makes uniqueness depend on + // the kind of package: custom rows resolve it to storage_id so they dedupe by content + // hash, so Arm and Intel of one version coexist while identical bytes are rejected. + // FMA rows resolve it to version, so version-uniqueness is unchanged and the same + // bytes can back several versions. A title holds only one kind, and a hash never + // equals a version string, so the two token spaces don't collide. The column is + // VIRTUAL so the add is in-place and its only consumer is the unique key below. if _, err := tx.Exec(` ALTER TABLE software_installers ADD COLUMN dedup_token VARCHAR(255) @@ -27,10 +27,9 @@ func Up_20260629163945(tx *sql.Tx) error { // Where a (global_or_team_id, title_id, dedup_token) has more than one row, keep the // first-added (smallest id) as the survivor and delete the rest, so the unique key - // below can be added. This collapses the duplicate-active custom rows from the P1 bug - // and any custom same-hash duplicates. FMA rows already satisfy version-uniqueness. - // Re-point policies off the deleted rows first, since policies.software_installer_id - // is RESTRICT. + // below can be added. This collapses duplicate-active custom rows and any custom + // same-hash duplicates. FMA rows already satisfy version-uniqueness. Re-point policies + // off the deleted rows first, since policies.software_installer_id is RESTRICT. const dupGroups = ` SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id FROM software_installers diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go index 5b42c76c04d..be01d0f2ca5 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -24,7 +24,7 @@ func TestUp_20260629163945(t *testing.T) { return execNoErrLastID(t, db, `INSERT INTO script_contents (contents, md5_checksum) VALUES ('#!/bin/sh', UNHEX(MD5(?)))`, seed) } - // teamID nil means no-team (global_or_team_id 0); fmaID nil means a custom package. + // teamID nil means no-team with global_or_team_id 0, fmaID nil means a custom package. args := func(titleID int64, teamID *int64, platform string, version string, storage string, fmaID *int64, active int) []any { script := insertScript(storage + version) var globalOrTeamID int64 @@ -96,7 +96,7 @@ func TestUp_20260629163945(t *testing.T) { applyNext(t, db) - // The version key is gone, replaced by the regime-aware dedup key. + // The version key is gone, replaced by the dedup_token key. require.Zero(t, countRows(` SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'software_installers' @@ -127,7 +127,7 @@ func TestUp_20260629163945(t *testing.T) { require.Equal(t, 1, countRows(`SELECT is_active FROM software_installers WHERE id = ?`, keepB)) require.Equal(t, 1, countRows(`SELECT is_active FROM software_installers WHERE id = ?`, keepC)) - // The survivor keeps its label and category; the deleted row's cascade away. + // The survivor keeps its label and category. The deleted row's cascade away. require.Equal(t, 1, countRows(`SELECT COUNT(*) FROM software_installer_labels WHERE software_installer_id = ?`, keepB)) require.Equal(t, 1, countRows(`SELECT COUNT(*) FROM software_installer_software_categories WHERE software_installer_id = ?`, keepB)) require.Zero(t, countRows(`SELECT COUNT(*) FROM software_installer_labels WHERE software_installer_id = ?`, dupB)) @@ -141,9 +141,9 @@ func TestUp_20260629163945(t *testing.T) { // FMA same-hash-different-version rows both survive. require.Equal(t, []int64{fmaOld, fmaActive}, remainingIDs(titleD)) - // New key behavior. Custom same-version-different-hash (Arm vs Intel) is accepted; - // these could not be seeded before the migration because the old version key blocked - // two rows sharing a version. + // New key behavior. Custom same-version-different-hash is accepted, and these could not + // be seeded before the migration because the old version key blocked two rows sharing a + // version. titleE := insertTitle("AppE", "apps") require.NoError(t, tryInsertInstaller(titleE, nil, "darwin", "9.0", "hash-e1", nil, 1)) require.NoError(t, tryInsertInstaller(titleE, nil, "darwin", "9.0", "hash-e2", nil, 1)) diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index b03ee212d93..3a2a14c1e3a 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -1279,7 +1279,7 @@ FROM WHERE si.title_id = ? AND si.global_or_team_id = ? AND si.is_active = 1 -ORDER BY si.uploaded_at DESC, si.id DESC +ORDER BY si.id ASC LIMIT 1`, scriptContentsSelect, scriptContentsFrom) @@ -1299,37 +1299,11 @@ LIMIT 1`, // TODO: do we want to include labels on other queries that return software installer metadata // (e.g., GetSoftwareInstallerMetadataByID)? - labels, err := ds.getSoftwareInstallerLabels(ctx, dest.InstallerID, softwareTypeInstaller) + dest.LabelsExcludeAny, dest.LabelsIncludeAny, dest.LabelsIncludeAll, err = ds.scopedSoftwareInstallerLabels(ctx, dest.InstallerID) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get software installer labels") - } - var exclAny, inclAny, inclAll []fleet.SoftwareScopeLabel - for _, l := range labels { - switch { - case l.Exclude && !l.RequireAll: - exclAny = append(exclAny, l) - case !l.Exclude && l.RequireAll: - inclAll = append(inclAll, l) - case !l.Exclude && !l.RequireAll: - inclAny = append(inclAny, l) - default: - ds.logger.WarnContext(ctx, "software installer has an unsupported label scope", "installer_id", dest.InstallerID, "invalid_label", fmt.Sprintf("%#v", l)) - } + return nil, err } - var count int - for _, set := range [][]fleet.SoftwareScopeLabel{exclAny, inclAny, inclAll} { - if len(set) > 0 { - count++ - } - } - if count > 1 { - ds.logger.WarnContext(ctx, "software installer has more than one scope of labels", "installer_id", dest.InstallerID, "include_any", fmt.Sprintf("%v", inclAny), "exclude_any", fmt.Sprintf("%v", exclAny), "include_all", fmt.Sprintf("%v", inclAll)) - } - dest.LabelsExcludeAny = exclAny - dest.LabelsIncludeAny = inclAny - dest.LabelsIncludeAll = inclAll - categoryMap, err := ds.GetCategoriesForSoftwareTitles(ctx, []uint{titleID}, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "getting categories for software installer metadata") @@ -1365,6 +1339,91 @@ LIMIT 1`, return &dest, nil } +func (ds *Datastore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + const query = ` +SELECT + si.id, + si.team_id, + si.title_id, + si.storage_id, + si.fleet_maintained_app_id, + si.package_ids, + si.upgrade_code, + si.filename, + si.extension, + si.version, + si.platform, + si.install_script_content_id, + si.pre_install_query, + si.post_install_script_content_id, + si.uninstall_script_content_id, + si.uploaded_at, + si.self_service, + si.url, + COALESCE(st.name, '') AS software_title, + COALESCE(st.bundle_identifier, '') AS bundle_identifier, + si.patch_query +FROM + software_installers si + JOIN software_titles st ON st.id = si.title_id +WHERE + si.title_id = ? AND si.global_or_team_id = ? + AND si.is_active = 1 +ORDER BY si.id ASC` + + var tmID uint + if teamID != nil { + tmID = *teamID + } + + var packages []*fleet.SoftwareInstaller + err := sqlx.SelectContext(ctx, ds.reader(ctx), &packages, query, titleID, tmID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "list software packages by team and title") + } + + for _, pkg := range packages { + pkg.LabelsExcludeAny, pkg.LabelsIncludeAny, pkg.LabelsIncludeAll, err = ds.scopedSoftwareInstallerLabels(ctx, pkg.InstallerID) + if err != nil { + return nil, err + } + } + + return packages, nil +} + +func (ds *Datastore) scopedSoftwareInstallerLabels(ctx context.Context, installerID uint) (excludeAny []fleet.SoftwareScopeLabel, includeAny []fleet.SoftwareScopeLabel, includeAll []fleet.SoftwareScopeLabel, err error) { + labels, err := ds.getSoftwareInstallerLabels(ctx, installerID, softwareTypeInstaller) + if err != nil { + return nil, nil, nil, ctxerr.Wrap(ctx, err, "get software installer labels") + } + + for _, l := range labels { + switch { + case l.Exclude && !l.RequireAll: + excludeAny = append(excludeAny, l) + case !l.Exclude && l.RequireAll: + includeAll = append(includeAll, l) + case !l.Exclude && !l.RequireAll: + includeAny = append(includeAny, l) + default: + ds.logger.WarnContext(ctx, "software installer has an unsupported label scope", "installer_id", installerID, "invalid_label", fmt.Sprintf("%#v", l)) + } + } + + var scopes int + for _, set := range [][]fleet.SoftwareScopeLabel{excludeAny, includeAny, includeAll} { + if len(set) > 0 { + scopes++ + } + } + if scopes > 1 { + ds.logger.WarnContext(ctx, "software installer has more than one scope of labels", "installer_id", installerID, "include_any", fmt.Sprintf("%v", includeAny), "exclude_any", fmt.Sprintf("%v", excludeAny), "include_all", fmt.Sprintf("%v", includeAll)) + } + + return excludeAny, includeAny, includeAll, nil +} + func (ds *Datastore) getSoftwareInstallerLabels(ctx context.Context, installerID uint, softwareType softwareType) ([]fleet.SoftwareScopeLabel, error) { query := fmt.Sprintf(` SELECT @@ -4140,7 +4199,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay func (ds *Datastore) checkFleetMaintainedAppExists(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (bool, error) { // look for the other kind of package on the title: an FMA when adding a custom // package, a custom package when adding an FMA. Matched by bundle identifier on - // macOS and by name or upgrade code on Windows; FMAs only exist on those platforms. + // macOS and by name or upgrade code on Windows. FMAs only exist on those platforms. wantFMA := payload.FleetMaintainedAppID == nil var stmt string var args []any diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 66f56b4fbf9..60833678e7e 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -40,6 +40,7 @@ func TestSoftwareInstallers(t *testing.T) { {"BatchSetSoftwareInstallersWithUpgradeCodes", testBatchSetSoftwareInstallersWithUpgradeCodes}, {"GetSoftwareInstallersPendingDeletion", testGetSoftwareInstallersPendingDeletion}, {"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID}, + {"GetSoftwarePackagesByTeamAndTitleID", testGetSoftwarePackagesByTeamAndTitleID}, {"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers}, {"DeleteSoftwareInstallers", testDeleteSoftwareInstallers}, {"testDeletePendingSoftwareInstallsForPolicy", testDeletePendingSoftwareInstallsForPolicy}, @@ -4442,6 +4443,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() @@ -6075,7 +6129,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data }) require.NoError(t, err) - // A title holds at most maxPackagesPerTitle packages; the next one is rejected. + // A title holds at most maxPackagesPerTitle packages, so the next one is rejected. for i := range maxPackagesPerTitle { _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: fmt.Sprintf("limit-storage-%d", i), diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 8c4f1036f62..c412aab345d 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -68,9 +68,9 @@ SELECT st.upgrade_code, COALESCE(sthc.hosts_count, 0) AS hosts_count, MAX(sthc.updated_at) AS counts_updated_at, - COUNT(si.id) as software_installers_count, - COUNT(vat.adam_id) AS vpp_apps_count, - COUNT(iha.id) AS in_house_apps_count, + COUNT(DISTINCT si.id) as software_installers_count, + COUNT(DISTINCT vat.adam_id) AS vpp_apps_count, + COUNT(DISTINCT iha.id) AS in_house_apps_count, %s vap.icon_url AS icon_url FROM software_titles st @@ -632,7 +632,10 @@ SELECT {{end}} FROM software_titles st {{if hasTeamID .}} - LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = {{teamID .}} AND si.is_active = TRUE + LEFT JOIN software_installers si ON si.id = ( + SELECT MIN(si2.id) FROM software_installers si2 + WHERE si2.title_id = st.id AND si2.global_or_team_id = {{teamID .}} AND si2.is_active = TRUE + ) LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND iha.global_or_team_id = {{teamID .}} LEFT JOIN vpp_apps vap ON vap.title_id = st.id AND {{yesNo .PackagesOnly "FALSE" "TRUE"}} LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform AND @@ -682,10 +685,10 @@ WHERE {{end}} {{end}} {{if and (hasTeamID $) $.HashSHA256}} - {{$additionalWhere = printf "%s AND si.storage_id = ?" $additionalWhere}} + {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers sif WHERE sif.title_id = st.id AND sif.global_or_team_id = %d AND sif.is_active = TRUE AND sif.storage_id = ?)" $additionalWhere (teamID $)}} {{end}} {{if and (hasTeamID $) $.PackageName}} - {{$additionalWhere = printf "%s AND si.filename = ?" $additionalWhere}} + {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers sif WHERE sif.title_id = st.id AND sif.global_or_team_id = %d AND sif.is_active = TRUE AND sif.filename = ?)" $additionalWhere (teamID $)}} {{end}} {{$additionalWhere}} {{end}} @@ -936,8 +939,13 @@ func buildOptimizedListSoftwareTitlesSQL(opts fleet.SoftwareTitleListOptions) st innerSQL, teamID, globalStats) if hasTeamID { + // A title can hold several active installers. Join only the first-added one so the + // title appears once with its primary package. outerSQL += fmt.Sprintf(` - LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = %[1]d AND si.is_active = TRUE + LEFT JOIN software_installers si ON si.id = ( + SELECT MIN(si2.id) FROM software_installers si2 + WHERE si2.title_id = st.id AND si2.global_or_team_id = %[1]d AND si2.is_active = TRUE + ) LEFT JOIN in_house_apps iha ON iha.title_id = st.id AND iha.global_or_team_id = %[1]d LEFT JOIN vpp_apps vap ON vap.title_id = st.id LEFT JOIN vpp_apps_teams vat ON vat.adam_id = vap.adam_id AND vat.platform = vap.platform diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 55d1f35bb28..1fe39c3a3ab 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2692,6 +2692,10 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) + // GetSoftwarePackagesByTeamAndTitleID returns every active package on the team + // and title, ordered first-added first, each with its label scope. + GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*SoftwareInstaller, error) + // GetFleetMaintainedVersionsByTitleID returns all cached versions of a // fleet-maintained app for the given title and team. If byVersion is true // the versions will be sorted by their version semver or string. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index c50a2baedbc..2b09fc27650 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1578,6 +1578,8 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type GetSoftwarePackagesByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) + type GetFleetMaintainedVersionsByTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) type ListFleetMaintainedAppActiveInstallersFunc func(ctx context.Context) ([]fleet.FMAAutoUpdateCandidate, error) @@ -4472,6 +4474,9 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwarePackagesByTeamAndTitleIDFunc GetSoftwarePackagesByTeamAndTitleIDFunc + GetSoftwarePackagesByTeamAndTitleIDFuncInvoked bool + GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFunc GetFleetMaintainedVersionsByTitleIDFuncInvoked bool @@ -10757,6 +10762,13 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*fleet.SoftwareInstaller, error) { + s.mu.Lock() + s.GetSoftwarePackagesByTeamAndTitleIDFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwarePackagesByTeamAndTitleIDFunc(ctx, teamID, titleID) +} + func (s *DataStore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) { s.mu.Lock() s.GetFleetMaintainedVersionsByTitleIDFuncInvoked = true From ec75765dca338aef54629da50529e8472219d3c6 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 17:17:45 -0400 Subject: [PATCH 04/10] Use defer for rows.Close in migration test and reword list-all doc (#48396) --- .../20260629163945_MultipleCustomPackagesPerTitle_test.go | 4 ++-- server/fleet/datastore.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go index be01d0f2ca5..f15eb7ee69b 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -50,13 +50,13 @@ func TestUp_20260629163945(t *testing.T) { var ids []int64 r, err := db.Query(`SELECT id FROM software_installers WHERE title_id = ? ORDER BY id`, titleID) require.NoError(t, err) + defer r.Close() for r.Next() { var id int64 require.NoError(t, r.Scan(&id)) ids = append(ids, id) } require.NoError(t, r.Err()) - require.NoError(t, r.Close()) return ids } @@ -108,13 +108,13 @@ func TestUp_20260629163945(t *testing.T) { AND index_name = 'idx_software_installers_dedup' ORDER BY seq_in_index`) require.NoError(t, err) + defer rows.Close() for rows.Next() { var col string require.NoError(t, rows.Scan(&col)) dedupCols = append(dedupCols, col) } require.NoError(t, rows.Err()) - require.NoError(t, rows.Close()) require.Equal(t, []string{"global_or_team_id", "title_id", "dedup_token"}, dedupCols) // Single-package title untouched. diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 1fe39c3a3ab..0e4a1ef72de 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2692,8 +2692,8 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) - // GetSoftwarePackagesByTeamAndTitleID returns every active package on the team - // and title, ordered first-added first, each with its label scope. + // GetSoftwarePackagesByTeamAndTitleID returns every active package for the given + // title and team, ordered first-added first, each with its label scope. GetSoftwarePackagesByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) ([]*SoftwareInstaller, error) // GetFleetMaintainedVersionsByTitleID returns all cached versions of a From ee04167a88de48609ecbfe893c97152f890c351b Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Mon, 29 Jun 2026 17:34:49 -0400 Subject: [PATCH 05/10] Test multiple active packages collapse to one title with correct count (#48396) --- ...29163945_MultipleCustomPackagesPerTitle.go | 6 +- .../mysql/software_installers_test.go | 33 +++++++---- .../datastore/mysql/software_titles_test.go | 53 ++++++++++++++++++ .../select_software_titles_sql_fixture.gz | Bin 35272 -> 36704 bytes 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go index 2f7eb2cad5c..c661137a198 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -16,10 +16,12 @@ func Up_20260629163945(tx *sql.Tx) error { // FMA rows resolve it to version, so version-uniqueness is unchanged and the same // bytes can back several versions. A title holds only one kind, and a hash never // equals a version string, so the two token spaces don't collide. The column is - // VIRTUAL so the add is in-place and its only consumer is the unique key below. + // VIRTUAL so the add is in-place and its only consumer is the unique key below. Its + // collation is pinned to match storage_id and version so the migration path does not + // inherit the server default collation that fresh installs never see. if _, err := tx.Exec(` ALTER TABLE software_installers - ADD COLUMN dedup_token VARCHAR(255) + ADD COLUMN dedup_token VARCHAR(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (IF(fleet_maintained_app_id IS NULL, storage_id, version)) VIRTUAL `); err != nil { return fmt.Errorf("adding dedup_token column: %w", err) diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 60833678e7e..72970e51da3 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -3777,16 +3777,28 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) { // Simulate the scenario from issue #42260: an FMA version update creates // a second row with the same storage_id but different version and is_active = 0. + // FMA rows dedupe by version, so the same bytes can back more than one version. // GetTeamsWithInstallerByHash must only return the active row. ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - _, err := q.ExecContext(ctx, ` + res, err := q.ExecContext(ctx, ` + INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier) + VALUES ('installer1', 'installer1/darwin', 'darwin', 'com.installer1.fma')`) + if err != nil { + return err + } + fmaID, err := res.LastInsertId() + if err != nil { + return err + } + _, err = q.ExecContext(ctx, ` INSERT INTO software_installers (team_id, global_or_team_id, storage_id, filename, extension, version, platform, title_id, - install_script_content_id, uninstall_script_content_id, is_active, url, package_ids, patch_query) + install_script_content_id, uninstall_script_content_id, is_active, url, package_ids, patch_query, + fleet_maintained_app_id) SELECT team_id, global_or_team_id, storage_id, filename, extension, 'old_version', platform, title_id, - install_script_content_id, uninstall_script_content_id, 0, url, package_ids, patch_query + install_script_content_id, uninstall_script_content_id, 0, url, package_ids, patch_query, ? FROM software_installers WHERE id = ? - `, installer1NoTeam) + `, fmaID, installer1NoTeam) return err }) @@ -5520,9 +5532,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) @@ -5578,7 +5591,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"` @@ -5592,8 +5605,8 @@ func testCustomToFMAInstallerReplacement(t *testing.T, ds *Datastore) { `, team2.ID, titles2[0].ID) }) require.Len(t, installerRows, 1) - require.Equal(t, customInstallerID2, installerRows[0].ID, "row should be updated in place, not deleted+re-inserted") - require.NotNil(t, installerRows[0].FMAID, "row should have been converted to FMA via ON DUPLICATE KEY UPDATE") + require.NotEqual(t, customInstallerID2, installerRows[0].ID, "custom row should be replaced by the FMA row") + require.NotNil(t, installerRows[0].FMAID, "row should have been converted to FMA") require.Equal(t, fma2.ID, *installerRows[0].FMAID) require.True(t, installerRows[0].IsActive) } diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index adb9f041faa..8d3b0112e3e 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -45,6 +45,7 @@ func TestSoftwareTitles(t *testing.T) { {"ListSoftwareTitlesByPlatform", testListSoftwareTitlesByPlatform}, {"UpdateAutoUpdateConfig", testUpdateAutoUpdateConfig}, {"ListSoftwareTitlesSortByDisplayName", testListSoftwareTitlesSortByDisplayName}, + {"ListSoftwareTitlesMultiplePackages", testListSoftwareTitlesMultiplePackages}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -891,6 +892,58 @@ func titleByName(titles []fleet.SoftwareTitleListResult, name string) fleet.Soft return fleet.SoftwareTitleListResult{} } +func testListSoftwareTitlesMultiplePackages(t *testing.T, ds *Datastore) { + ctx := context.Background() + user := test.NewUser(t, ds, "Multi", "multi@example.com", true) + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "multi-pkg-team"}) + require.NoError(t, err) + + mk := func(storage string, filename string) *fleet.UploadSoftwareInstallerPayload { + return &fleet.UploadSoftwareInstallerPayload{ + Title: "Multi App", + Source: "apps", + BundleIdentifier: "com.example.multi", + Platform: "darwin", + Extension: "pkg", + Version: "1.0", + InstallScript: "echo", + Filename: filename, + StorageID: storage, + UserID: user.ID, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + TeamID: &team.ID, + } + } + + // two active packages on one title (same version, different content) + _, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, mk("multi-a", "a.pkg")) + require.NoError(t, err) + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mk("multi-b", "b.pkg")) + require.NoError(t, err) + + adminFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}} + countMultiApp := func(opts fleet.SoftwareTitleListOptions) int { + titles, _, _, err := ds.ListSoftwareTitles(ctx, opts, adminFilter) + require.NoError(t, err) + var n int + for _, tl := range titles { + if tl.Name == "Multi App" { + n++ + } + } + return n + } + + // the title appears once on the optimized path (no filters) and the filtered path + require.Equal(t, 1, countMultiApp(fleet.SoftwareTitleListOptions{TeamID: &team.ID})) + require.Equal(t, 1, countMultiApp(fleet.SoftwareTitleListOptions{TeamID: &team.ID, ListOptions: fleet.ListOptions{MatchQuery: "Multi App"}})) + + // the installer count reflects both packages + title, err := ds.SoftwareTitleByID(ctx, titleID, &team.ID, adminFilter) + require.NoError(t, err) + require.Equal(t, 2, title.SoftwareInstallersCount) +} + func testListSoftwareTitlesInstallersOnly(t *testing.T, ds *Datastore) { ctx := context.Background() diff --git a/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz b/server/datastore/mysql/testdata/select_software_titles_sql_fixture.gz index 082d2e213b5c4edf440fa8a3bc865c74698a8499..a4cb086f234874c22cf1b156daf45cc8bc3267f0 100644 GIT binary patch literal 36704 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 literal 35272 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�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%0DYuDU+07wYGZ>O9IkCY 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 Mi^)$db^*};0m7|gasU7T From 8265e24ad6a4fac4b3c4df46aa94b164900cbf64 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Tue, 30 Jun 2026 13:23:29 -0400 Subject: [PATCH 06/10] Restore team-wide script dedup and apply review cleanups (#48396) --- ...29163945_MultipleCustomPackagesPerTitle.go | 26 +++++------ server/datastore/mysql/software_installers.go | 42 +++++++++++------- .../mysql/software_installers_test.go | 43 +++++++++---------- server/datastore/mysql/software_titles.go | 4 +- .../datastore/mysql/software_titles_test.go | 25 +++++------ server/fleet/errors.go | 10 ++--- server/fleet/software_installer.go | 3 ++ 7 files changed, 79 insertions(+), 74 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go index c661137a198..792d2a3a121 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -10,15 +10,11 @@ func init() { } func Up_20260629163945(tx *sql.Tx) error { - // A title may now have more than one package. dedup_token makes uniqueness depend on - // the kind of package: custom rows resolve it to storage_id so they dedupe by content - // hash, so Arm and Intel of one version coexist while identical bytes are rejected. - // FMA rows resolve it to version, so version-uniqueness is unchanged and the same - // bytes can back several versions. A title holds only one kind, and a hash never - // equals a version string, so the two token spaces don't collide. The column is - // VIRTUAL so the add is in-place and its only consumer is the unique key below. Its - // collation is pinned to match storage_id and version so the migration path does not - // inherit the server default collation that fresh installs never see. + // A title can now hold several packages. dedup_token drives the new unique key. Custom + // rows resolve it to storage_id so they dedupe by content hash, letting different builds of + // one version coexist. FMA rows resolve it to version, leaving the per-version rows that + // back version pinning unchanged. VIRTUAL keeps the add in-place. The collation is pinned + // to match storage_id and version so the migration matches what fresh installs get. if _, err := tx.Exec(` ALTER TABLE software_installers ADD COLUMN dedup_token VARCHAR(255) COLLATE utf8mb4_unicode_ci @@ -27,11 +23,9 @@ func Up_20260629163945(tx *sql.Tx) error { return fmt.Errorf("adding dedup_token column: %w", err) } - // Where a (global_or_team_id, title_id, dedup_token) has more than one row, keep the - // first-added (smallest id) as the survivor and delete the rest, so the unique key - // below can be added. This collapses duplicate-active custom rows and any custom - // same-hash duplicates. FMA rows already satisfy version-uniqueness. Re-point policies - // off the deleted rows first, since policies.software_installer_id is RESTRICT. + // Collapse rows that would violate the new key: keep the lowest id per group and delete + // the rest. Re-point policies off the deleted rows first, since + // policies.software_installer_id is RESTRICT. const dupGroups = ` SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id FROM software_installers @@ -47,7 +41,7 @@ func Up_20260629163945(tx *sql.Tx) error { AND si.title_id = dup.title_id AND si.dedup_token = dup.dedup_token SET p.software_installer_id = dup.keep_id - WHERE si.id <> dup.keep_id`, dupGroups)); err != nil { + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { return fmt.Errorf("re-pointing policies off duplicate installers: %w", err) } @@ -57,7 +51,7 @@ func Up_20260629163945(tx *sql.Tx) error { ON si.global_or_team_id = dup.global_or_team_id AND si.title_id = dup.title_id AND si.dedup_token = dup.dedup_token - WHERE si.id <> dup.keep_id`, dupGroups)); err != nil { + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { return fmt.Errorf("deleting duplicate installers: %w", err) } diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 3a2a14c1e3a..41a0ff49a26 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -198,7 +198,8 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, errors.New("validated labels must not be nil") } - if err := ds.checkSoftwareConflictsByIdentifier(ctx, payload); err != nil { + err = ds.checkSoftwareConflictsByIdentifier(ctx, payload) + if err != nil { return 0, 0, err } @@ -229,6 +230,23 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload return 0, 0, ctxerr.Wrap(ctx, err, "get or generate software installer title ID") } + // Script packages dedupe by content team-wide: identical bytes are the same script, so + // they can't be added under a different title. Same-title duplicates are already caught + // by the per-title hash check. Binary installers can legitimately ship the same content + // with different install scripts, so they are not deduped this way. + if payload.StorageID != "" && fleet.IsScriptPackage(payload.Extension) { + teamsByHash, err := ds.GetTeamsWithInstallerByHash(ctx, payload.StorageID, "") + if err != nil { + return 0, 0, ctxerr.Wrap(ctx, err, "check duplicate installer by hash") + } + if _, exists := teamsByHash[ptr.ValOrZero(payload.TeamID)]; exists { + return 0, 0, fleet.NewInvalidArgumentError( + "software", + "Couldn't add software. An installer with identical contents already exists on this fleet.", + ) + } + } + if err := ds.addSoftwareTitleToMatchingSoftware(ctx, titleID, payload); err != nil { return 0, 0, ctxerr.Wrap(ctx, err, "add software title to matching software") } @@ -1371,13 +1389,8 @@ WHERE AND si.is_active = 1 ORDER BY si.id ASC` - var tmID uint - if teamID != nil { - tmID = *teamID - } - var packages []*fleet.SoftwareInstaller - err := sqlx.SelectContext(ctx, ds.reader(ctx), &packages, query, titleID, tmID) + err := sqlx.SelectContext(ctx, ds.reader(ctx), &packages, query, titleID, ptr.ValOrZero(teamID)) if err != nil { return nil, ctxerr.Wrap(ctx, err, "list software packages by team and title") } @@ -4092,8 +4105,6 @@ LIMIT 1` return &installer, nil } -const maxPackagesPerTitle = 10 - func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) error { conflict := func(message string) error { teamName, err := ds.getTeamName(ctx, payload.TeamID) @@ -4162,7 +4173,8 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay return err } - // check if a custom package with the same hash exists + // A package can't repeat the same bytes within its title. Scripts also dedupe + // team-wide in MatchOrCreateSoftwareInstaller. var dup bool err = sqlx.GetContext(ctx, ds.reader(ctx), &dup, ` SELECT EXISTS ( @@ -4178,7 +4190,7 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay }, "duplicate package by hash") } - // a title holds at most maxPackagesPerTitle custom packages + // a title holds at most fleet.MaxPackagesPerTitle custom packages var count int err = sqlx.GetContext(ctx, ds.reader(ctx), &count, ` SELECT COUNT(*) FROM software_installers @@ -4186,9 +4198,9 @@ func (ds *Datastore) checkSoftwareConflictsByIdentifier(ctx context.Context, pay if err != nil { return ctxerr.Wrap(ctx, err, "count packages on the title") } - if count >= maxPackagesPerTitle { + if count >= fleet.MaxPackagesPerTitle { return ctxerr.Wrap(ctx, fleet.ConflictError{ - Message: fmt.Sprintf(fleet.SoftwarePackageLimitMessage, payload.Title, maxPackagesPerTitle), + Message: fmt.Sprintf(fleet.SoftwarePackageLimitMessage, payload.Title, fleet.MaxPackagesPerTitle), }, "package limit reached") } } @@ -4220,10 +4232,10 @@ func (ds *Datastore) checkFleetMaintainedAppExists(ctx context.Context, payload SELECT 1 FROM software_installers si JOIN software_titles st ON st.id = si.title_id - WHERE si.global_or_team_id = ? AND st.source = ? AND (st.name = ? OR (? != '' AND st.upgrade_code = ?)) + WHERE si.global_or_team_id = ? AND st.source = ? AND (st.name = ? OR (st.upgrade_code != '' AND st.upgrade_code = ?)) AND (si.fleet_maintained_app_id IS NOT NULL) = ? )` - args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.Title, payload.UpgradeCode, payload.UpgradeCode, wantFMA} + args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.Title, payload.UpgradeCode, wantFMA} default: return false, nil } diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 72970e51da3..95349f006f9 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -3779,18 +3779,15 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) { // a second row with the same storage_id but different version and is_active = 0. // FMA rows dedupe by version, so the same bytes can back more than one version. // GetTeamsWithInstallerByHash must only return the active row. + fma, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{ + Name: "installer1", + Slug: "installer1/darwin", + Platform: "darwin", + UniqueIdentifier: "com.installer1.fma", + }) + require.NoError(t, err) ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { - res, err := q.ExecContext(ctx, ` - INSERT INTO fleet_maintained_apps (name, slug, platform, unique_identifier) - VALUES ('installer1', 'installer1/darwin', 'darwin', 'com.installer1.fma')`) - if err != nil { - return err - } - fmaID, err := res.LastInsertId() - if err != nil { - return err - } - _, err = q.ExecContext(ctx, ` + _, err := q.ExecContext(ctx, ` INSERT INTO software_installers (team_id, global_or_team_id, storage_id, filename, extension, version, platform, title_id, install_script_content_id, uninstall_script_content_id, is_active, url, package_ids, patch_query, @@ -3798,7 +3795,7 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) { SELECT team_id, global_or_team_id, storage_id, filename, extension, 'old_version', platform, title_id, install_script_content_id, uninstall_script_content_id, 0, url, package_ids, patch_query, ? FROM software_installers WHERE id = ? - `, fmaID, installer1NoTeam) + `, fma.ID, installer1NoTeam) return err }) @@ -4542,9 +4539,10 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "a.sh", "title-a")) require.NoError(t, err) - // Same hash under a different title on the same team → allowed (dedupe is title-scoped) + // Duplicate on Team A with different name/title but same hash → reject _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "b.sh", "title-b")) - require.NoError(t, err) + var iae *fleet.InvalidArgumentError + require.ErrorAs(t, err, &iae) // Same hash on different team → allowed _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamB.ID, "c.sh", "title-c")) @@ -4554,9 +4552,10 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(nil, "global1.sh", "title-g1")) require.NoError(t, err) - // Global scope, same hash under a different title → allowed + // Global scope second time (duplicate hash) → reject _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(nil, "global2.sh", "title-g2")) - require.NoError(t, err) + var iae2 *fleet.InvalidArgumentError + require.ErrorAs(t, err, &iae2) // Test that binary packages (.pkg) with duplicate hash ARE allowed mkPkgPayload := func(teamID *uint, filename, title string) *fleet.UploadSoftwareInstallerPayload { @@ -4583,7 +4582,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateHash(t *testing.T, ds *Datastore _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPkgPayload(&teamA.ID, "pkg2.pkg", "title-pkg2")) require.NoError(t, err, "binary packages with same hash should be allowed on same team") - // Same title and hash on the same team → rejected by hash + // Same title and hash on the same team → rejected by the within-title hash check _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, mkPayload(&teamA.ID, "a.sh", "title-a")) require.ErrorContains(t, err, "same SHA-256 hash") } @@ -6113,7 +6112,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data // Same title and version but different content is allowed (e.g. Arm vs Intel builds). _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - StorageID: "arch-arm-storage", + StorageID: "arch-storage-arm", Filename: "arch-app-arm.pkg", Title: "Arch App", BundleIdentifier: "com.example.arch", @@ -6128,7 +6127,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data require.NoError(t, err) _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ - StorageID: "arch-intel-storage", + StorageID: "arch-storage-intel", Filename: "arch-app-intel.pkg", Title: "Arch App", BundleIdentifier: "com.example.arch", @@ -6142,8 +6141,8 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data }) require.NoError(t, err) - // A title holds at most maxPackagesPerTitle packages, so the next one is rejected. - for i := range maxPackagesPerTitle { + // A title holds at most fleet.MaxPackagesPerTitle packages, so the next one is rejected. + for i := range fleet.MaxPackagesPerTitle { _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ StorageID: fmt.Sprintf("limit-storage-%d", i), Filename: fmt.Sprintf("limit-%d.msi", i), @@ -6170,7 +6169,7 @@ func testMatchOrCreateSoftwareInstallerDuplicateConflicts(t *testing.T, ds *Data ValidatedLabels: &fleet.LabelIdentsWithScope{}, TeamID: &team.ID, }) - require.ErrorContains(t, err, fmt.Sprintf("already has %d packages", maxPackagesPerTitle)) + require.ErrorContains(t, err, fmt.Sprintf("already has %d packages", fleet.MaxPackagesPerTitle)) } func testGetSoftwareTitlesForInstallAll(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index c412aab345d..aa295ed20a4 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -685,10 +685,10 @@ WHERE {{end}} {{end}} {{if and (hasTeamID $) $.HashSHA256}} - {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers sif WHERE sif.title_id = st.id AND sif.global_or_team_id = %d AND sif.is_active = TRUE AND sif.storage_id = ?)" $additionalWhere (teamID $)}} + {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers si2 WHERE si2.title_id = st.id AND si2.global_or_team_id = %d AND si2.is_active = TRUE AND si2.storage_id = ?)" $additionalWhere (teamID $)}} {{end}} {{if and (hasTeamID $) $.PackageName}} - {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers sif WHERE sif.title_id = st.id AND sif.global_or_team_id = %d AND sif.is_active = TRUE AND sif.filename = ?)" $additionalWhere (teamID $)}} + {{$additionalWhere = printf "%s AND EXISTS (SELECT 1 FROM software_installers si2 WHERE si2.title_id = st.id AND si2.global_or_team_id = %d AND si2.is_active = TRUE AND si2.filename = ?)" $additionalWhere (teamID $)}} {{end}} {{$additionalWhere}} {{end}} diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index 8d3b0112e3e..68cc11dcddc 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -922,21 +922,18 @@ func testListSoftwareTitlesMultiplePackages(t *testing.T, ds *Datastore) { require.NoError(t, err) adminFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}} - countMultiApp := func(opts fleet.SoftwareTitleListOptions) int { - titles, _, _, err := ds.ListSoftwareTitles(ctx, opts, adminFilter) - require.NoError(t, err) - var n int - for _, tl := range titles { - if tl.Name == "Multi App" { - n++ - } - } - return n - } - // the title appears once on the optimized path (no filters) and the filtered path - require.Equal(t, 1, countMultiApp(fleet.SoftwareTitleListOptions{TeamID: &team.ID})) - require.Equal(t, 1, countMultiApp(fleet.SoftwareTitleListOptions{TeamID: &team.ID, ListOptions: fleet.ListOptions{MatchQuery: "Multi App"}})) + // the team has only this title, so it must appear exactly once (not duplicated by the two + // active packages) on both the optimized path (no filters) and the filtered path + titles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID}, adminFilter) + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, "Multi App", titles[0].Name) + + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{TeamID: &team.ID, ListOptions: fleet.ListOptions{MatchQuery: "Multi App"}}, adminFilter) + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, "Multi App", titles[0].Name) // the installer count reflects both packages title, err := ds.SoftwareTitleByID(ctx, titleID, &team.ID, adminFilter) diff --git a/server/fleet/errors.go b/server/fleet/errors.go index c7c87ba7af1..aa5c4d5590c 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -37,11 +37,11 @@ var ( CantEnablePINRequiredIfDiskEncryptionEnabled = "Couldn't enable BitLocker PIN requirement, you must enable disk encryption first." CantResendAppleDeclarationProfilesMessage = "Can't resend declaration (DDM) profiles. Unlike configuration profiles (.mobileconfig), the host automatically checks in to get the latest DDM profiles." CantAddSoftwareConflictMessage = "Couldn't add software. %s already has an installer available for the %s fleet." - SoftwarePackageHashConflictMessage = "Couldn't add. %s package is already added (same SHA-256 hash)." - SoftwareAlreadyHasVPPAppMessage = "Couldn't add. %s already has an Apple App Store (VPP) on the %s fleet." - SoftwareAlreadyHasFleetMaintainedAppMessage = "Couldn't add. %s already has a Fleet-maintained app on the %s fleet." - SoftwareAlreadyHasPackageMessage = "Couldn't add. %s already has a software package on the %s fleet." - SoftwarePackageLimitMessage = "Couldn't add. %s already has %d packages. Before adding, delete one you no longer use." + SoftwarePackageHashConflictMessage = "%s package is already added (same SHA-256 hash)." + SoftwareAlreadyHasVPPAppMessage = "%s already has an Apple App Store (VPP) on the %s fleet." + SoftwareAlreadyHasFleetMaintainedAppMessage = "%s already has a Fleet-maintained app on the %s fleet." + SoftwareAlreadyHasPackageMessage = "%s already has a software package on the %s fleet." + SoftwarePackageLimitMessage = "%s already has %d packages. Before adding, delete one you no longer use." ConfigProfileLabelScopingPremiumCauseMsg = "Scoping configuration profiles with labels" ) diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index d1827410059..a8926be5b4c 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -1195,6 +1195,9 @@ type SoftwareScopeLabel struct { // Max total attempts (including initial) for a non-policy software install. const MaxSoftwareInstallAttempts = 3 +// MaxPackagesPerTitle caps how many custom packages a single software title can hold per team. +const MaxPackagesPerTitle = 10 + // HostSoftwareInstallOptions contains options that apply to a software or VPP // app install request. type HostSoftwareInstallOptions struct { From e835606dbd9ba39cfe693e1bf88ab4298fc6cf37 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Wed, 1 Jul 2026 16:18:17 -0400 Subject: [PATCH 07/10] check timestamps --- .../20260629163945_MultipleCustomPackagesPerTitle.go | 5 +++-- .../20260629163945_MultipleCustomPackagesPerTitle_test.go | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go index 792d2a3a121..09ac0d2a540 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -25,7 +25,8 @@ func Up_20260629163945(tx *sql.Tx) error { // 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. + // policies.software_installer_id is RESTRICT. Keep policies.updated_at so this + // content-identical swap doesn't read as a policy edit. const dupGroups = ` SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id FROM software_installers @@ -40,7 +41,7 @@ func Up_20260629163945(tx *sql.Tx) error { ON si.global_or_team_id = dup.global_or_team_id AND si.title_id = dup.title_id AND si.dedup_token = dup.dedup_token - SET p.software_installer_id = dup.keep_id + SET p.software_installer_id = dup.keep_id, p.updated_at = p.updated_at WHERE si.id != dup.keep_id`, dupGroups)); err != nil { return fmt.Errorf("re-pointing policies off duplicate installers: %w", err) } diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go index f15eb7ee69b..0fb61b9d176 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -82,6 +82,8 @@ func TestUp_20260629163945(t *testing.T) { policyID := execNoErrLastID(t, db, ` INSERT INTO policies (name, query, description, checksum, software_installer_id) VALUES ('p1', 'SELECT 1', '', UNHEX(MD5('p1')), ?)`, dupB) + // Freeze updated_at so the re-point can be checked for not bumping it. + execNoErr(t, db, `UPDATE policies SET updated_at = '2020-01-01 00:00:00' WHERE id = ?`, policyID) // Custom hash-duplicate scoped to a team, different source. titleC := insertTitle("AppC", "rpm_packages") @@ -133,10 +135,13 @@ func TestUp_20260629163945(t *testing.T) { 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. + // The policy was re-pointed off the deleted row onto the survivor, without bumping updated_at. var repointed int64 require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM policies WHERE id = ?`, policyID).Scan(&repointed)) require.Equal(t, keepB, repointed) + var updatedAtUnchanged bool + require.NoError(t, db.QueryRow(`SELECT updated_at = '2020-01-01 00:00:00' FROM policies WHERE id = ?`, policyID).Scan(&updatedAtUnchanged)) + require.True(t, updatedAtUnchanged) // FMA same-hash-different-version rows both survive. require.Equal(t, []int64{fmaOld, fmaActive}, remainingIDs(titleD)) From db0b67c95d758d80182ab357cc69a25568a33aff Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Thu, 2 Jul 2026 11:41:33 -0400 Subject: [PATCH 08/10] Keep active installer when collapsing duplicates in migration (#48396) --- ...60629163945_MultipleCustomPackagesPerTitle.go | 12 +++++++----- ...163945_MultipleCustomPackagesPerTitle_test.go | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go index 09ac0d2a540..9f71e0310fe 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -23,12 +23,14 @@ func Up_20260629163945(tx *sql.Tx) error { 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. Keep policies.updated_at so this - // content-identical swap doesn't read as a policy edit. + // Collapse rows that would violate the new key: keep the first-added active row per group + // (the row the reads return), or the lowest id if none is active, and delete the rest. + // Re-point policies off the deleted rows first, since policies.software_installer_id is + // RESTRICT. Keep policies.updated_at so this content-identical swap doesn't read as a + // policy edit. const dupGroups = ` - SELECT global_or_team_id, title_id, dedup_token, MIN(id) AS keep_id + SELECT global_or_team_id, title_id, dedup_token, + COALESCE(MIN(CASE WHEN is_active = 1 THEN id END), MIN(id)) AS keep_id FROM software_installers WHERE title_id IS NOT NULL GROUP BY global_or_team_id, title_id, dedup_token diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go index 0fb61b9d176..daa1e629eb0 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -96,6 +96,16 @@ func TestUp_20260629163945(t *testing.T) { fmaOld := insertInstaller(titleD, nil, "darwin", "1.0", "hash-d", &fma, 0) fmaActive := insertInstaller(titleD, nil, "darwin", "2.0", "hash-d", &fma, 1) + // Custom hash-duplicate where the first-added row is inactive and a later row is active. + // The active row must be the survivor to match the is_active reads, even though it is not + // the lowest id. A policy points at the inactive row that gets deleted. + titleF := insertTitle("AppF", "apps") + inactiveF := insertInstaller(titleF, nil, "darwin", "1.0", "hash-f", nil, 0) + activeF := insertInstaller(titleF, nil, "darwin", "2.0", "hash-f", nil, 1) + policyF := execNoErrLastID(t, db, ` + INSERT INTO policies (name, query, description, checksum, software_installer_id) + VALUES ('pf', 'SELECT 1', '', UNHEX(MD5('pf')), ?)`, inactiveF) + applyNext(t, db) // The version key is gone, replaced by the dedup_token key. @@ -146,6 +156,12 @@ func TestUp_20260629163945(t *testing.T) { // FMA same-hash-different-version rows both survive. require.Equal(t, []int64{fmaOld, fmaActive}, remainingIDs(titleD)) + // The active row is retained over the lower-id inactive one, and the policy re-points to it. + require.Equal(t, []int64{activeF}, remainingIDs(titleF)) + var repointedF int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM policies WHERE id = ?`, policyF).Scan(&repointedF)) + require.Equal(t, activeF, repointedF) + // New key behavior. Custom same-version-different-hash is accepted, and these could not // be seeded before the migration because the old version key blocked two rows sharing a // version. From d94721f57a0b8eb77db07f384a87c0cc34556f0e Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Thu, 2 Jul 2026 17:17:58 -0400 Subject: [PATCH 09/10] Re-point setup experience and pending installs in migration, guard macOS bundle match (#48396) --- ...29163945_MultipleCustomPackagesPerTitle.go | 29 +++++++++++++++++++ ...945_MultipleCustomPackagesPerTitle_test.go | 17 +++++++++++ server/datastore/mysql/software_installers.go | 6 ++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go index 9f71e0310fe..30df92f27c3 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go @@ -48,6 +48,35 @@ func Up_20260629163945(tx *sql.Tx) error { return fmt.Errorf("re-pointing policies off duplicate installers: %w", err) } + // setup_experience_software_installers has an ON DELETE CASCADE FK, so a selection that + // lived only on a deleted duplicate would be silently dropped. Re-point those rows onto the + // survivor first. UPDATE IGNORE skips a row when the survivor already has that platform. + if _, err := tx.Exec(fmt.Sprintf(` + UPDATE IGNORE setup_experience_software_installers sesi + JOIN software_installers si ON si.id = sesi.software_installer_id + JOIN (%s) dup + ON si.global_or_team_id = dup.global_or_team_id + AND si.title_id = dup.title_id + AND si.dedup_token = dup.dedup_token + SET sesi.software_installer_id = dup.keep_id + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("re-pointing setup experience installers off duplicate installers: %w", err) + } + + // software_install_upcoming_activities has an ON DELETE SET NULL FK, so a queued install on + // a deleted duplicate would be silently orphaned. Re-point pending installs onto the survivor. + if _, err := tx.Exec(fmt.Sprintf(` + UPDATE software_install_upcoming_activities siua + JOIN software_installers si ON si.id = siua.software_installer_id + JOIN (%s) dup + ON si.global_or_team_id = dup.global_or_team_id + AND si.title_id = dup.title_id + AND si.dedup_token = dup.dedup_token + SET siua.software_installer_id = dup.keep_id, siua.updated_at = siua.updated_at + WHERE si.id != dup.keep_id`, dupGroups)); err != nil { + return fmt.Errorf("re-pointing upcoming install activities off duplicate installers: %w", err) + } + if _, err := tx.Exec(fmt.Sprintf(` DELETE si FROM software_installers si JOIN (%s) dup diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go index daa1e629eb0..5e351b4b61d 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go +++ b/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go @@ -84,6 +84,11 @@ func TestUp_20260629163945(t *testing.T) { VALUES ('p1', 'SELECT 1', '', UNHEX(MD5('p1')), ?)`, dupB) // Freeze updated_at so the re-point can be checked for not bumping it. execNoErr(t, db, `UPDATE policies SET updated_at = '2020-01-01 00:00:00' WHERE id = ?`, policyID) + // A setup experience selection living only on the deleted row (FK is ON DELETE CASCADE). + execNoErr(t, db, `INSERT INTO setup_experience_software_installers (software_installer_id, platform, global_or_team_id) VALUES (?, 'windows', 0)`, dupB) + // A pending install queued on the deleted row (FK is ON DELETE SET NULL). + upcomingID := execNoErrLastID(t, db, `INSERT INTO upcoming_activities (host_id, activity_type, execution_id, payload) VALUES (1, 'software_install', 'dup-install-exec', '{}')`) + execNoErr(t, db, `INSERT INTO software_install_upcoming_activities (upcoming_activity_id, software_installer_id, software_title_id) VALUES (?, ?, ?)`, upcomingID, dupB, titleB) // Custom hash-duplicate scoped to a team, different source. titleC := insertTitle("AppC", "rpm_packages") @@ -153,6 +158,18 @@ func TestUp_20260629163945(t *testing.T) { require.NoError(t, db.QueryRow(`SELECT updated_at = '2020-01-01 00:00:00' FROM policies WHERE id = ?`, policyID).Scan(&updatedAtUnchanged)) require.True(t, updatedAtUnchanged) + // The setup experience selection on the deleted row was re-pointed to the survivor, not + // dropped by the ON DELETE CASCADE. + var setupExperienceInstaller int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM setup_experience_software_installers WHERE platform = 'windows'`).Scan(&setupExperienceInstaller)) + require.Equal(t, keepB, setupExperienceInstaller) + + // The pending install on the deleted row was re-pointed to the survivor, not orphaned by + // the ON DELETE SET NULL. + var upcomingInstaller int64 + require.NoError(t, db.QueryRow(`SELECT software_installer_id FROM software_install_upcoming_activities WHERE upcoming_activity_id = ?`, upcomingID).Scan(&upcomingInstaller)) + require.Equal(t, keepB, upcomingInstaller) + // FMA same-hash-different-version rows both survive. require.Equal(t, []int64{fmaOld, fmaActive}, remainingIDs(titleD)) diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 41a0ff49a26..c65157795a8 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -4215,8 +4215,8 @@ func (ds *Datastore) checkFleetMaintainedAppExists(ctx context.Context, payload wantFMA := payload.FleetMaintainedAppID == nil var stmt string var args []any - switch payload.Platform { - case string(fleet.MacOSPlatform): + switch { + case payload.Platform == string(fleet.MacOSPlatform) && payload.BundleIdentifier != "": stmt = ` SELECT EXISTS ( SELECT 1 @@ -4226,7 +4226,7 @@ func (ds *Datastore) checkFleetMaintainedAppExists(ctx context.Context, payload AND (si.fleet_maintained_app_id IS NOT NULL) = ? )` args = []any{ptr.ValOrZero(payload.TeamID), payload.Source, payload.BundleIdentifier, wantFMA} - case "windows": + case payload.Platform == "windows": stmt = ` SELECT EXISTS ( SELECT 1 From f104d5d72827aa70c439f9a186285a7f08313ce2 Mon Sep 17 00:00:00 2001 From: Jonathan Katz Date: Thu, 2 Jul 2026 20:34:17 -0400 Subject: [PATCH 10/10] Bump migration timestamp past #48664 (#48396) --- ...go => 20260702232839_MultipleCustomPackagesPerTitle.go} | 6 +++--- ... 20260702232839_MultipleCustomPackagesPerTitle_test.go} | 2 +- server/datastore/mysql/schema.sql | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) rename server/datastore/mysql/migrations/tables/{20260629163945_MultipleCustomPackagesPerTitle.go => 20260702232839_MultipleCustomPackagesPerTitle.go} (96%) rename server/datastore/mysql/migrations/tables/{20260629163945_MultipleCustomPackagesPerTitle_test.go => 20260702232839_MultipleCustomPackagesPerTitle_test.go} (99%) diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go b/server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle.go similarity index 96% rename from server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go rename to server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle.go index 30df92f27c3..33789beb603 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle.go +++ b/server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle.go @@ -6,10 +6,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20260629163945, Down_20260629163945) + MigrationClient.AddMigration(Up_20260702232839, Down_20260702232839) } -func Up_20260629163945(tx *sql.Tx) error { +func Up_20260702232839(tx *sql.Tx) error { // A title can now hold several packages. dedup_token drives the new unique key. Custom // rows resolve it to storage_id so they dedupe by content hash, letting different builds of // one version coexist. FMA rows resolve it to version, leaving the per-version rows that @@ -98,6 +98,6 @@ func Up_20260629163945(tx *sql.Tx) error { return nil } -func Down_20260629163945(tx *sql.Tx) error { +func Down_20260702232839(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go b/server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle_test.go similarity index 99% rename from server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go rename to server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle_test.go index 5e351b4b61d..faeda31f7ca 100644 --- a/server/datastore/mysql/migrations/tables/20260629163945_MultipleCustomPackagesPerTitle_test.go +++ b/server/datastore/mysql/migrations/tables/20260702232839_MultipleCustomPackagesPerTitle_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestUp_20260629163945(t *testing.T) { +func TestUp_20260702232839(t *testing.T) { db := applyUpToPrev(t) insertTitle := func(name string, source string) int64 { diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 68fd80aa309..8b32becb25a 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -2089,9 +2089,9 @@ CREATE TABLE `migration_status_tables` ( `is_applied` tinyint(1) NOT NULL, `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) -) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=560 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=561 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'),(268,20240601174138,1,'2020-01-01 01:01:01'),(269,20240607133721,1,'2020-01-01 01:01:01'),(270,20240612150059,1,'2020-01-01 01:01:01'),(271,20240613162201,1,'2020-01-01 01:01:01'),(272,20240613172616,1,'2020-01-01 01:01:01'),(273,20240618142419,1,'2020-01-01 01:01:01'),(274,20240625093543,1,'2020-01-01 01:01:01'),(275,20240626195531,1,'2020-01-01 01:01:01'),(276,20240702123921,1,'2020-01-01 01:01:01'),(277,20240703154849,1,'2020-01-01 01:01:01'),(278,20240707134035,1,'2020-01-01 01:01:01'),(279,20240707134036,1,'2020-01-01 01:01:01'),(280,20240709124958,1,'2020-01-01 01:01:01'),(281,20240709132642,1,'2020-01-01 01:01:01'),(282,20240709183940,1,'2020-01-01 01:01:01'),(283,20240710155623,1,'2020-01-01 01:01:01'),(284,20240723102712,1,'2020-01-01 01:01:01'),(285,20240725152735,1,'2020-01-01 01:01:01'),(286,20240725182118,1,'2020-01-01 01:01:01'),(287,20240726100517,1,'2020-01-01 01:01:01'),(288,20240730171504,1,'2020-01-01 01:01:01'),(289,20240730174056,1,'2020-01-01 01:01:01'),(290,20240730215453,1,'2020-01-01 01:01:01'),(291,20240730374423,1,'2020-01-01 01:01:01'),(292,20240801115359,1,'2020-01-01 01:01:01'),(293,20240802101043,1,'2020-01-01 01:01:01'),(294,20240802113716,1,'2020-01-01 01:01:01'),(295,20240814135330,1,'2020-01-01 01:01:01'),(296,20240815000000,1,'2020-01-01 01:01:01'),(297,20240815000001,1,'2020-01-01 01:01:01'),(298,20240816103247,1,'2020-01-01 01:01:01'),(299,20240820091218,1,'2020-01-01 01:01:01'),(300,20240826111228,1,'2020-01-01 01:01:01'),(301,20240826160025,1,'2020-01-01 01:01:01'),(302,20240829165448,1,'2020-01-01 01:01:01'),(303,20240829165605,1,'2020-01-01 01:01:01'),(304,20240829165715,1,'2020-01-01 01:01:01'),(305,20240829165930,1,'2020-01-01 01:01:01'),(306,20240829170023,1,'2020-01-01 01:01:01'),(307,20240829170033,1,'2020-01-01 01:01:01'),(308,20240829170044,1,'2020-01-01 01:01:01'),(309,20240905105135,1,'2020-01-01 01:01:01'),(310,20240905140514,1,'2020-01-01 01:01:01'),(311,20240905200000,1,'2020-01-01 01:01:01'),(312,20240905200001,1,'2020-01-01 01:01:01'),(313,20241002104104,1,'2020-01-01 01:01:01'),(314,20241002104105,1,'2020-01-01 01:01:01'),(315,20241002104106,1,'2020-01-01 01:01:01'),(316,20241002210000,1,'2020-01-01 01:01:01'),(317,20241003145349,1,'2020-01-01 01:01:01'),(318,20241004005000,1,'2020-01-01 01:01:01'),(319,20241008083925,1,'2020-01-01 01:01:01'),(320,20241009090010,1,'2020-01-01 01:01:01'),(321,20241017163402,1,'2020-01-01 01:01:01'),(322,20241021224359,1,'2020-01-01 01:01:01'),(323,20241022140321,1,'2020-01-01 01:01:01'),(324,20241025111236,1,'2020-01-01 01:01:01'),(325,20241025112748,1,'2020-01-01 01:01:01'),(326,20241025141855,1,'2020-01-01 01:01:01'),(327,20241110152839,1,'2020-01-01 01:01:01'),(328,20241110152840,1,'2020-01-01 01:01:01'),(329,20241110152841,1,'2020-01-01 01:01:01'),(330,20241116233322,1,'2020-01-01 01:01:01'),(331,20241122171434,1,'2020-01-01 01:01:01'),(332,20241125150614,1,'2020-01-01 01:01:01'),(333,20241203125346,1,'2020-01-01 01:01:01'),(334,20241203130032,1,'2020-01-01 01:01:01'),(335,20241205122800,1,'2020-01-01 01:01:01'),(336,20241209164540,1,'2020-01-01 01:01:01'),(337,20241210140021,1,'2020-01-01 01:01:01'),(338,20241219180042,1,'2020-01-01 01:01:01'),(339,20241220100000,1,'2020-01-01 01:01:01'),(340,20241220114903,1,'2020-01-01 01:01:01'),(341,20241220114904,1,'2020-01-01 01:01:01'),(342,20241224000000,1,'2020-01-01 01:01:01'),(343,20241230000000,1,'2020-01-01 01:01:01'),(344,20241231112624,1,'2020-01-01 01:01:01'),(345,20250102121439,1,'2020-01-01 01:01:01'),(346,20250121094045,1,'2020-01-01 01:01:01'),(347,20250121094500,1,'2020-01-01 01:01:01'),(348,20250121094600,1,'2020-01-01 01:01:01'),(349,20250121094700,1,'2020-01-01 01:01:01'),(350,20250124194347,1,'2020-01-01 01:01:01'),(351,20250127162751,1,'2020-01-01 01:01:01'),(352,20250213104005,1,'2020-01-01 01:01:01'),(353,20250214205657,1,'2020-01-01 01:01:01'),(354,20250217093329,1,'2020-01-01 01:01:01'),(355,20250219090511,1,'2020-01-01 01:01:01'),(356,20250219100000,1,'2020-01-01 01:01:01'),(357,20250219142401,1,'2020-01-01 01:01:01'),(358,20250224184002,1,'2020-01-01 01:01:01'),(359,20250225085436,1,'2020-01-01 01:01:01'),(360,20250226000000,1,'2020-01-01 01:01:01'),(361,20250226153445,1,'2020-01-01 01:01:01'),(362,20250304162702,1,'2020-01-01 01:01:01'),(363,20250306144233,1,'2020-01-01 01:01:01'),(364,20250313163430,1,'2020-01-01 01:01:01'),(365,20250317130944,1,'2020-01-01 01:01:01'),(366,20250318165922,1,'2020-01-01 01:01:01'),(367,20250320132525,1,'2020-01-01 01:01:01'),(368,20250320200000,1,'2020-01-01 01:01:01'),(369,20250326161930,1,'2020-01-01 01:01:01'),(370,20250326161931,1,'2020-01-01 01:01:01'),(371,20250331042354,1,'2020-01-01 01:01:01'),(372,20250331154206,1,'2020-01-01 01:01:01'),(373,20250401155831,1,'2020-01-01 01:01:01'),(374,20250408133233,1,'2020-01-01 01:01:01'),(375,20250410104321,1,'2020-01-01 01:01:01'),(376,20250421085116,1,'2020-01-01 01:01:01'),(377,20250422095806,1,'2020-01-01 01:01:01'),(378,20250424153059,1,'2020-01-01 01:01:01'),(379,20250430103833,1,'2020-01-01 01:01:01'),(380,20250430112622,1,'2020-01-01 01:01:01'),(381,20250501162727,1,'2020-01-01 01:01:01'),(382,20250502154517,1,'2020-01-01 01:01:01'),(383,20250502222222,1,'2020-01-01 01:01:01'),(384,20250507170845,1,'2020-01-01 01:01:01'),(385,20250513162912,1,'2020-01-01 01:01:01'),(386,20250519161614,1,'2020-01-01 01:01:01'),(387,20250519170000,1,'2020-01-01 01:01:01'),(388,20250520153848,1,'2020-01-01 01:01:01'),(389,20250528115932,1,'2020-01-01 01:01:01'),(390,20250529102706,1,'2020-01-01 01:01:01'),(391,20250603105558,1,'2020-01-01 01:01:01'),(392,20250609102714,1,'2020-01-01 01:01:01'),(393,20250609112613,1,'2020-01-01 01:01:01'),(394,20250613103810,1,'2020-01-01 01:01:01'),(395,20250616193950,1,'2020-01-01 01:01:01'),(396,20250624140757,1,'2020-01-01 01:01:01'),(397,20250626130239,1,'2020-01-01 01:01:01'),(398,20250629131032,1,'2020-01-01 01:01:01'),(399,20250701155654,1,'2020-01-01 01:01:01'),(400,20250707095725,1,'2020-01-01 01:01:01'),(401,20250716152435,1,'2020-01-01 01:01:01'),(402,20250718091828,1,'2020-01-01 01:01:01'),(403,20250728122229,1,'2020-01-01 01:01:01'),(404,20250731122715,1,'2020-01-01 01:01:01'),(405,20250731151000,1,'2020-01-01 01:01:01'),(406,20250803000000,1,'2020-01-01 01:01:01'),(407,20250805083116,1,'2020-01-01 01:01:01'),(408,20250807140441,1,'2020-01-01 01:01:01'),(409,20250808000000,1,'2020-01-01 01:01:01'),(410,20250811155036,1,'2020-01-01 01:01:01'),(411,20250813205039,1,'2020-01-01 01:01:01'),(412,20250814123333,1,'2020-01-01 01:01:01'),(413,20250815130115,1,'2020-01-01 01:01:01'),(414,20250816115553,1,'2020-01-01 01:01:01'),(415,20250817154557,1,'2020-01-01 01:01:01'),(416,20250825113751,1,'2020-01-01 01:01:01'),(417,20250827113140,1,'2020-01-01 01:01:01'),(418,20250828120836,1,'2020-01-01 01:01:01'),(419,20250902112642,1,'2020-01-01 01:01:01'),(420,20250904091745,1,'2020-01-01 01:01:01'),(421,20250905090000,1,'2020-01-01 01:01:01'),(422,20250922083056,1,'2020-01-01 01:01:01'),(423,20250923120000,1,'2020-01-01 01:01:01'),(424,20250926123048,1,'2020-01-01 01:01:01'),(425,20251015103505,1,'2020-01-01 01:01:01'),(426,20251015103600,1,'2020-01-01 01:01:01'),(427,20251015103700,1,'2020-01-01 01:01:01'),(428,20251015103800,1,'2020-01-01 01:01:01'),(429,20251015103900,1,'2020-01-01 01:01:01'),(430,20251028140000,1,'2020-01-01 01:01:01'),(431,20251028140100,1,'2020-01-01 01:01:01'),(432,20251028140110,1,'2020-01-01 01:01:01'),(433,20251028140200,1,'2020-01-01 01:01:01'),(434,20251028140300,1,'2020-01-01 01:01:01'),(435,20251028140400,1,'2020-01-01 01:01:01'),(436,20251031154558,1,'2020-01-01 01:01:01'),(437,20251103160848,1,'2020-01-01 01:01:01'),(438,20251104112849,1,'2020-01-01 01:01:01'),(439,20251106000000,1,'2020-01-01 01:01:01'),(440,20251107164629,1,'2020-01-01 01:01:01'),(441,20251107170854,1,'2020-01-01 01:01:01'),(442,20251110172137,1,'2020-01-01 01:01:01'),(443,20251111153133,1,'2020-01-01 01:01:01'),(444,20251117020000,1,'2020-01-01 01:01:01'),(445,20251117020100,1,'2020-01-01 01:01:01'),(446,20251117020200,1,'2020-01-01 01:01:01'),(447,20251121100000,1,'2020-01-01 01:01:01'),(448,20251121124239,1,'2020-01-01 01:01:01'),(449,20251124090450,1,'2020-01-01 01:01:01'),(450,20251124135808,1,'2020-01-01 01:01:01'),(451,20251124140138,1,'2020-01-01 01:01:01'),(452,20251124162948,1,'2020-01-01 01:01:01'),(453,20251127113559,1,'2020-01-01 01:01:01'),(454,20251202162232,1,'2020-01-01 01:01:01'),(455,20251203170808,1,'2020-01-01 01:01:01'),(456,20251207050413,1,'2020-01-01 01:01:01'),(457,20251208215800,1,'2020-01-01 01:01:01'),(458,20251209221730,1,'2020-01-01 01:01:01'),(459,20251209221850,1,'2020-01-01 01:01:01'),(460,20251215163721,1,'2020-01-01 01:01:01'),(461,20251217000000,1,'2020-01-01 01:01:01'),(462,20251217120000,1,'2020-01-01 01:01:01'),(463,20251229000000,1,'2020-01-01 01:01:01'),(464,20251229000010,1,'2020-01-01 01:01:01'),(465,20251229000020,1,'2020-01-01 01:01:01'),(466,20260106000000,1,'2020-01-01 01:01:01'),(467,20260108200708,1,'2020-01-01 01:01:01'),(468,20260108214732,1,'2020-01-01 01:01:01'),(469,20260109231821,1,'2020-01-01 01:01:01'),(470,20260113012054,1,'2020-01-01 01:01:01'),(471,20260124200020,1,'2020-01-01 01:01:01'),(472,20260126150840,1,'2020-01-01 01:01:01'),(473,20260126210724,1,'2020-01-01 01:01:01'),(474,20260202151756,1,'2020-01-01 01:01:01'),(475,20260205184907,1,'2020-01-01 01:01:01'),(476,20260210151544,1,'2020-01-01 01:01:01'),(477,20260210155109,1,'2020-01-01 01:01:01'),(478,20260210181120,1,'2020-01-01 01:01:01'),(479,20260211200153,1,'2020-01-01 01:01:01'),(480,20260217141240,1,'2020-01-01 01:01:01'),(481,20260217200906,1,'2020-01-01 01:01:01'),(482,20260218175704,1,'2020-01-01 01:01:01'),(483,20260314120000,1,'2020-01-01 01:01:01'),(484,20260316120000,1,'2020-01-01 01:01:01'),(485,20260316120001,1,'2020-01-01 01:01:01'),(486,20260316120002,1,'2020-01-01 01:01:01'),(487,20260316120003,1,'2020-01-01 01:01:01'),(488,20260316120004,1,'2020-01-01 01:01:01'),(489,20260316120005,1,'2020-01-01 01:01:01'),(490,20260316120006,1,'2020-01-01 01:01:01'),(491,20260316120007,1,'2020-01-01 01:01:01'),(492,20260316120008,1,'2020-01-01 01:01:01'),(493,20260316120009,1,'2020-01-01 01:01:01'),(494,20260316120010,1,'2020-01-01 01:01:01'),(495,20260317120000,1,'2020-01-01 01:01:01'),(496,20260318184559,1,'2020-01-01 01:01:01'),(497,20260319120000,1,'2020-01-01 01:01:01'),(498,20260323144117,1,'2020-01-01 01:01:01'),(499,20260324161944,1,'2020-01-01 01:01:01'),(500,20260324223334,1,'2020-01-01 01:01:01'),(501,20260326131501,1,'2020-01-01 01:01:01'),(502,20260326210603,1,'2020-01-01 01:01:01'),(503,20260331000000,1,'2020-01-01 01:01:01'),(504,20260401153000,1,'2020-01-01 01:01:01'),(505,20260401153001,1,'2020-01-01 01:01:01'),(506,20260401153503,1,'2020-01-01 01:01:01'),(507,20260403120000,1,'2020-01-01 01:01:01'),(508,20260409153713,1,'2020-01-01 01:01:01'),(509,20260409153714,1,'2020-01-01 01:01:01'),(510,20260409153715,1,'2020-01-01 01:01:01'),(511,20260409153716,1,'2020-01-01 01:01:01'),(512,20260409153717,1,'2020-01-01 01:01:01'),(513,20260409183610,1,'2020-01-01 01:01:01'),(514,20260410173222,1,'2020-01-01 01:01:01'),(515,20260422181702,1,'2020-01-01 01:01:01'),(516,20260423161823,1,'2020-01-01 01:01:01'),(517,20260423161824,1,'2020-01-01 01:01:01'),(518,20260518194422,1,'2020-01-01 01:01:01'),(519,20260522195224,1,'2020-01-01 01:01:01'),(520,20260522195225,1,'2020-01-01 01:01:01'),(521,20260522195226,1,'2020-01-01 01:01:01'),(522,20260522195227,1,'2020-01-01 01:01:01'),(523,20260522195229,1,'2020-01-01 01:01:01'),(524,20260522195230,1,'2020-01-01 01:01:01'),(525,20260522195231,1,'2020-01-01 01:01:01'),(526,20260522195232,1,'2020-01-01 01:01:01'),(527,20260522195233,1,'2020-01-01 01:01:01'),(528,20260522195234,1,'2020-01-01 01:01:01'),(529,20260522195235,1,'2020-01-01 01:01:01'),(530,20260527215817,1,'2020-01-01 01:01:01'),(531,20260527215818,1,'2020-01-01 01:01:01'),(532,20260528201143,1,'2020-01-01 01:01:01'),(533,20260528201150,1,'2020-01-01 01:01:01'),(534,20260528211626,1,'2020-01-01 01:01:01'),(535,20260528213326,1,'2020-01-01 01:01:01'),(536,20260529091823,1,'2020-01-01 01:01:01'),(537,20260529120000,1,'2020-01-01 01:01:01'),(538,20260601200727,1,'2020-01-01 01:01:01'),(539,20260603101320,1,'2020-01-01 01:01:01'),(540,20260603120000,1,'2020-01-01 01:01:01'),(541,20260604221206,1,'2020-01-01 01:01:01'),(542,20260605195941,1,'2020-01-01 01:01:01'),(543,20260606051849,1,'2020-01-01 01:01:01'),(544,20260608160653,1,'2020-01-01 01:01:01'),(545,20260608202705,1,'2020-01-01 01:01:01'),(546,20260608210432,1,'2020-01-01 01:01:01'),(547,20260610172952,1,'2020-01-01 01:01:01'),(548,20260624210253,1,'2020-01-01 01:01:01'),(549,20260624210311,1,'2020-01-01 01:01:01'),(550,20260626120000,1,'2020-01-01 01:01:01'),(551,20260702013055,1,'2020-01-01 01:01:01'),(552,20260702013056,1,'2020-01-01 01:01:01'),(553,20260702013057,1,'2020-01-01 01:01:01'),(554,20260702013058,1,'2020-01-01 01:01:01'),(555,20260702013059,1,'2020-01-01 01:01:01'),(556,20260702013100,1,'2020-01-01 01:01:01'),(557,20260702013101,1,'2020-01-01 01:01:01'),(558,20260702013102,1,'2020-01-01 01:01:01'),(559,20260702164518,1,'2020-01-01 01:01:01'),(560,20260702232839,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -3034,8 +3034,9 @@ CREATE TABLE `software_installers` ( `is_active` tinyint(1) NOT NULL DEFAULT '0', `patch_query` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `http_etag` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `dedup_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (if((`fleet_maintained_app_id` is null),`storage_id`,`version`)) VIRTUAL, PRIMARY KEY (`id`), - UNIQUE KEY `idx_software_installers_team_title_version` (`global_or_team_id`,`title_id`,`version`), + UNIQUE KEY `idx_software_installers_dedup` (`global_or_team_id`,`title_id`,`dedup_token`), KEY `fk_software_installers_title` (`title_id`), KEY `fk_software_installers_install_script_content_id` (`install_script_content_id`), KEY `fk_software_installers_post_install_script_content_id` (`post_install_script_content_id`),