From 1253b645284d172f72195a90fdbc61d2b4837115 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 11 Nov 2025 16:38:54 -0500 Subject: [PATCH] Add gitops support for in house apps (#35423) --- .../33164-support-gitops-for-in-house-apps | 1 + cmd/fleetctl/fleetctl/generate_gitops.go | 14 + cmd/fleetctl/fleetctl/get_test.go | 3 + cmd/fleetctl/fleetctl/gitops_test.go | 23 +- .../no_team_software_installer_subdir_ipa.yml | 6 + .../no_team_software_installer_valid_ipa.yml | 6 + .../testdata/gitops/subdir/installer_ipa.yml | 1 + .../team_software_installer_subdir_ipa.yml | 17 + .../team_software_installer_valid_ipa.yml | 18 + .../fleetctl/testing_utils/testing_utils.go | 14 +- .../gitops_enterprise_integration_test.go | 225 +++- .../integrationtest/gitops/software_test.go | 23 +- ee/server/service/software_installers.go | 200 +++- server/datastore/mysql/in_house_apps.go | 639 +++++++++- server/datastore/mysql/in_house_apps_test.go | 1061 ++++++++++++++++- .../20251111153133_AddUrlToInHouseApps.go | 25 + ...20251111153133_AddUrlToInHouseApps_test.go | 13 + server/datastore/mysql/schema.sql | 5 +- server/datastore/mysql/software_installers.go | 75 +- .../mysql/software_installers_test.go | 50 +- server/datastore/mysql/testing_utils.go | 1 + server/fleet/datastore.go | 4 +- server/fleet/software_installer.go | 22 +- server/mock/datastore_mock.go | 16 +- .../software-installers/ipa_test2.ipa | Bin 0 -> 20650 bytes 25 files changed, 2337 insertions(+), 125 deletions(-) create mode 100644 changes/33164-support-gitops-for-in-house-apps create mode 100644 cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_subdir_ipa.yml create mode 100644 cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_valid_ipa.yml create mode 100644 cmd/fleetctl/fleetctl/testdata/gitops/subdir/installer_ipa.yml create mode 100644 cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_subdir_ipa.yml create mode 100644 cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_valid_ipa.yml create mode 100644 server/datastore/mysql/migrations/tables/20251111153133_AddUrlToInHouseApps.go create mode 100644 server/datastore/mysql/migrations/tables/20251111153133_AddUrlToInHouseApps_test.go create mode 100644 server/service/testdata/software-installers/ipa_test2.ipa diff --git a/changes/33164-support-gitops-for-in-house-apps b/changes/33164-support-gitops-for-in-house-apps new file mode 100644 index 0000000000..e145c7cbad --- /dev/null +++ b/changes/33164-support-gitops-for-in-house-apps @@ -0,0 +1 @@ +* Added support for in-house (".ipa") apps to `fleetctl gitops`. diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index fd8c52ac02..102c4e471c 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -7,6 +7,7 @@ import ( "math" "os" pathUtils "path" + "path/filepath" "reflect" "regexp" "slices" @@ -1404,10 +1405,23 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint, result := make(map[string]interface{}) packages := make([]map[string]interface{}, 0) appStoreApps := make([]map[string]interface{}, 0) + + // in-house apps generate two software titles for the same gitops entry: one + // for iOS and one for iPadOS. Use this set to deduplicate them (by filename, + // which is unique for a given team and platform). + dedupeInHouseAppsByFilename := make(map[string]struct{}) for _, sw := range software { softwareSpec := make(map[string]interface{}) switch { case sw.SoftwarePackage != nil: + if isInHouseApp := filepath.Ext(sw.SoftwarePackage.Name) == ".ipa"; isInHouseApp { + if _, ok := dedupeInHouseAppsByFilename[sw.SoftwarePackage.Name]; ok { + // ignore duplicate in-house app + continue + } + dedupeInHouseAppsByFilename[sw.SoftwarePackage.Name] = struct{}{} + } + pkgName := "" if sw.SoftwarePackage.Name != "" { pkgName = fmt.Sprintf(" (%s)", sw.SoftwarePackage.Name) diff --git a/cmd/fleetctl/fleetctl/get_test.go b/cmd/fleetctl/fleetctl/get_test.go index 0884c62691..6e07a08618 100644 --- a/cmd/fleetctl/fleetctl/get_test.go +++ b/cmd/fleetctl/fleetctl/get_test.go @@ -2472,6 +2472,9 @@ func TestGetTeamsYAMLAndApply(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } actualYaml := RunAppForTest(t, []string{"get", "teams", "--yaml"}) yamlFilePath := writeTmpYml(t, actualYaml) diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index 98050782f3..39e217e2ed 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -316,6 +316,9 @@ func TestGitOpsBasicGlobalPremium(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { return nil, nil } @@ -684,6 +687,9 @@ func TestGitOpsBasicTeam(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { return nil, nil } @@ -1205,8 +1211,8 @@ func TestGitOpsFullTeam(t *testing.T) { return team, nil } - ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint]*fleet.ExistingSoftwareInstaller, error) { - return map[uint]*fleet.ExistingSoftwareInstaller{}, nil + ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { + return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } // Policies @@ -1284,6 +1290,9 @@ func TestGitOpsFullTeam(t *testing.T) { } return nil } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { return nil, nil } @@ -1637,6 +1646,9 @@ func TestGitOpsBasicGlobalAndTeam(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { return nil, nil } @@ -2010,6 +2022,9 @@ func TestGitOpsBasicGlobalAndNoTeam(t *testing.T) { ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { return nil, nil } @@ -2379,8 +2394,8 @@ func TestGitOpsFullGlobalAndTeam(t *testing.T) { ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) { return 0, nil } - ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint]*fleet.ExistingSoftwareInstaller, error) { - return map[uint]*fleet.ExistingSoftwareInstaller{}, nil + ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { + return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { return []uint{}, nil diff --git a/cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_subdir_ipa.yml b/cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_subdir_ipa.yml new file mode 100644 index 0000000000..d19b5057f5 --- /dev/null +++ b/cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_subdir_ipa.yml @@ -0,0 +1,6 @@ +name: No team +controls: +policies: +software: + packages: + - path: subdir/installer_ipa.yml diff --git a/cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_valid_ipa.yml b/cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_valid_ipa.yml new file mode 100644 index 0000000000..8267e5740d --- /dev/null +++ b/cmd/fleetctl/fleetctl/testdata/gitops/no_team_software_installer_valid_ipa.yml @@ -0,0 +1,6 @@ +name: No team +controls: +policies: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa diff --git a/cmd/fleetctl/fleetctl/testdata/gitops/subdir/installer_ipa.yml b/cmd/fleetctl/fleetctl/testdata/gitops/subdir/installer_ipa.yml new file mode 100644 index 0000000000..324bb57638 --- /dev/null +++ b/cmd/fleetctl/fleetctl/testdata/gitops/subdir/installer_ipa.yml @@ -0,0 +1 @@ +url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa diff --git a/cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_subdir_ipa.yml b/cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_subdir_ipa.yml new file mode 100644 index 0000000000..721ba0d279 --- /dev/null +++ b/cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_subdir_ipa.yml @@ -0,0 +1,17 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - path: ./subdir/installer_ipa.yml diff --git a/cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_valid_ipa.yml b/cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_valid_ipa.yml new file mode 100644 index 0000000000..4277eb2bad --- /dev/null +++ b/cmd/fleetctl/fleetctl/testdata/gitops/team_software_installer_valid_ipa.yml @@ -0,0 +1,18 @@ +name: "${TEST_TEAM_NAME}" +team_settings: + secrets: + - secret: "ABC" + features: + enable_host_users: true + enable_software_inventory: true + host_expiry_settings: + host_expiry_enabled: true + host_expiry_window: 30 +agent_options: +controls: +policies: +queries: +software: + packages: + - url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa + self_service: true diff --git a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go index 3038ceba27..0a499c9b1f 100644 --- a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go @@ -181,10 +181,14 @@ func ServeMDMBootstrapPackage(t *testing.T, pkgPath, pkgName string) (*httptest. } func StartSoftwareInstallerServer(t *testing.T) { - // start the web server that will serve the installer + // load the ruby installer to use as base bytes to repeat for the "too large" case b, err := os.ReadFile(getPathRelative("../../../../server/service/testdata/software-installers/ruby.deb")) require.NoError(t, err) + // get the base dir of all installers + baseDir := getPathRelative("../../../../server/service/testdata/software-installers/") + + // start the web server that will serve the installer srv := httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -203,9 +207,12 @@ func StartSoftwareInstallerServer(t *testing.T) { n, _ := w.Write(b) sz += n } - default: + case strings.Contains(r.URL.Path, "other.deb"): + // serve same content as ruby.deb w.Header().Set("Content-Type", "application/vnd.debian.binary-package") _, _ = w.Write(b) + default: + http.ServeFile(w, r, filepath.Join(baseDir, filepath.Base(r.URL.Path))) } }, ), @@ -403,6 +410,9 @@ func SetupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig, ds.BatchSetSoftwareInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { return nil } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, teamID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { return nil, nil } diff --git a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go index 1bc8e770f8..3f58f3173e 100644 --- a/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go +++ b/cmd/fleetctl/integrationtest/gitops/gitops_enterprise_integration_test.go @@ -144,6 +144,10 @@ func (s *enterpriseIntegrationGitopsTestSuite) TearDownTest() { _, err := tx.ExecContext(ctx, "DELETE FROM vpp_apps;") return err }) + mysql.ExecAdhocSQL(t, s.DS, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, "DELETE FROM in_house_apps;") + return err + }) lbls, err := s.DS.ListLabels(ctx, fleet.TeamFilter{User: test.UserAdmin}, fleet.ListOptions{}) require.NoError(t, err) @@ -1948,7 +1952,7 @@ queries: labels: - name: my-label label_membership_type: manual - hosts: + hosts: - %s - %s - %d @@ -1978,3 +1982,222 @@ labels: // Verify the correct hosts were added to the label require.ElementsMatch(t, labelHostIDs, []uint{host1.ID, host2.ID, host3.ID, host5.ID}) } + +func (s *enterpriseIntegrationGitopsTestSuite) TestIPASoftwareInstallers() { + t := s.T() + ctx := context.Background() + + user := s.createGitOpsUser(t) + fleetctlConfig := s.createFleetctlConfig(t, user) + lbl, err := s.DS.NewLabel(ctx, &fleet.Label{Name: "Label1", Query: "SELECT 1"}) + require.NoError(t, err) + require.NotZero(t, lbl.ID) + + const ( + globalTemplate = ` +agent_options: +controls: +org_settings: + server_settings: + server_url: $FLEET_URL + org_info: + org_name: Fleet + secrets: +policies: +queries: +labels: + - name: Label1 + label_membership_type: dynamic + query: SELECT 1 +` + + noTeamTemplate = `name: No team +controls: +policies: +software: + packages: +%s +` + teamTemplate = ` +controls: +software: + packages: +%s +queries: +policies: +agent_options: +name: %s +team_settings: + secrets: [{"secret":"enroll_secret"}] +` + ) + + globalFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = globalFile.WriteString(globalTemplate) + require.NoError(t, err) + err = globalFile.Close() + require.NoError(t, err) + + // create an .ipa software for the no-team config + noTeamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = noTeamFile.WriteString(fmt.Sprintf(noTeamTemplate, ` + - url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa + self_service: true +`)) + require.NoError(t, err) + err = noTeamFile.Close() + require.NoError(t, err) + noTeamFilePath := filepath.Join(filepath.Dir(noTeamFile.Name()), "no-team.yml") + err = os.Rename(noTeamFile.Name(), noTeamFilePath) + require.NoError(t, err) + + // Set the required environment variables + t.Setenv("FLEET_URL", s.Server.URL) + testing_utils.StartSoftwareInstallerServer(t) + + _ = fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath, "--dry-run"}) + _ = fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", noTeamFilePath}) + + // the ipa installer was created for no team + titles, _, _, err := s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: ptr.Uint(0)}, + fleet.TeamFilter{User: test.UserAdmin}) + require.NoError(t, err) + + require.Len(t, titles, 2) + var sources, platforms []string + for _, title := range titles { + require.Equal(t, "ipa_test", title.Name) + require.NotNil(t, title.BundleIdentifier) + require.Equal(t, "com.ipa-test.ipa-test", *title.BundleIdentifier) + sources = append(sources, title.Source) + + require.NotNil(t, title.SoftwarePackage) + platforms = append(platforms, title.SoftwarePackage.Platform) + require.Equal(t, "ipa_test.ipa", title.SoftwarePackage.Name) + + meta, err := s.DS.GetInHouseAppMetadataByTeamAndTitleID(ctx, nil, title.ID) + require.NoError(t, err) + require.True(t, meta.SelfService) + require.Empty(t, meta.LabelsExcludeAny) + require.Empty(t, meta.LabelsIncludeAny) + } + require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources) + require.ElementsMatch(t, []string{"ios", "ipados"}, platforms) + + // create a dummy install script, should be ignored for ipa apps + scriptFile, err := os.CreateTemp(t.TempDir(), "*.sh") + require.NoError(t, err) + _, err = scriptFile.WriteString(`echo "dummy install script"`) + require.NoError(t, err) + err = scriptFile.Close() + require.NoError(t, err) + + // create an .ipa software for the team config + teamName := uuid.NewString() + teamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + _, err = teamFile.WriteString(fmt.Sprintf(teamTemplate, ` + - url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa + self_service: false + install_script: + path: `+scriptFile.Name()+` + labels_include_any: + - Label1 +`, teamName)) + require.NoError(t, err) + err = teamFile.Close() + require.NoError(t, err) + + _ = fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}) + _ = fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}) + + // get the team ID + team, err := s.DS.TeamByName(ctx, teamName) + require.NoError(t, err) + + // the ipa installer was created for the team + titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID}, + fleet.TeamFilter{User: test.UserAdmin}) + require.NoError(t, err) + + require.Len(t, titles, 2) + sources, platforms = []string{}, []string{} + for _, title := range titles { + require.Equal(t, "ipa_test", title.Name) + require.NotNil(t, title.BundleIdentifier) + require.Equal(t, "com.ipa-test.ipa-test", *title.BundleIdentifier) + sources = append(sources, title.Source) + + require.NotNil(t, title.SoftwarePackage) + platforms = append(platforms, title.SoftwarePackage.Platform) + require.Equal(t, "ipa_test.ipa", title.SoftwarePackage.Name) + + meta, err := s.DS.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, title.ID) + require.NoError(t, err) + require.False(t, meta.SelfService) + require.Empty(t, meta.LabelsExcludeAny) + require.Len(t, meta.LabelsIncludeAny, 1) + require.Equal(t, lbl.ID, meta.LabelsIncludeAny[0].LabelID) + require.Empty(t, meta.InstallScript) // install script should be ignored for ipa apps + } + require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources) + require.ElementsMatch(t, []string{"ios", "ipados"}, platforms) + + // update the team config to clear the label condition + err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, ` + - url: ${SOFTWARE_INSTALLER_URL}/ipa_test.ipa + labels_include_any: +`, teamName)), 0o644) + require.NoError(t, err) + + _ = fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}) + _ = fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}) + + // the ipa installer was created for the team + titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID}, + fleet.TeamFilter{User: test.UserAdmin}) + require.NoError(t, err) + + require.Len(t, titles, 2) + sources, platforms = []string{}, []string{} + for _, title := range titles { + require.Equal(t, "ipa_test", title.Name) + require.NotNil(t, title.BundleIdentifier) + require.Equal(t, "com.ipa-test.ipa-test", *title.BundleIdentifier) + sources = append(sources, title.Source) + + require.NotNil(t, title.SoftwarePackage) + platforms = append(platforms, title.SoftwarePackage.Platform) + require.Equal(t, "ipa_test.ipa", title.SoftwarePackage.Name) + + meta, err := s.DS.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, title.ID) + require.NoError(t, err) + require.False(t, meta.SelfService) + require.Empty(t, meta.LabelsExcludeAny) + require.Empty(t, meta.LabelsIncludeAny) + } + require.ElementsMatch(t, []string{"ios_apps", "ipados_apps"}, sources) + require.ElementsMatch(t, []string{"ios", "ipados"}, platforms) + + // update the team config to clear all installers + err = os.WriteFile(teamFile.Name(), []byte(fmt.Sprintf(teamTemplate, "", teamName)), 0o644) + require.NoError(t, err) + + _ = fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name(), "--dry-run"}) + _ = fleetctl.RunAppForTest(t, + []string{"gitops", "--config", fleetctlConfig.Name(), "-f", globalFile.Name(), "-f", teamFile.Name()}) + + titles, _, _, err = s.DS.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{AvailableForInstall: true, TeamID: &team.ID}, + fleet.TeamFilter{User: test.UserAdmin}) + require.NoError(t, err) + require.Len(t, titles, 0) +} diff --git a/cmd/fleetctl/integrationtest/gitops/software_test.go b/cmd/fleetctl/integrationtest/gitops/software_test.go index 67968d3d6b..acc328c80a 100644 --- a/cmd/fleetctl/integrationtest/gitops/software_test.go +++ b/cmd/fleetctl/integrationtest/gitops/software_test.go @@ -34,7 +34,7 @@ func TestGitOpsTeamSoftwareInstallers(t *testing.T) { }{ {"testdata/gitops/team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."}, {"testdata/gitops/team_software_installer_install_script_secret.yml", "environment variable \"FLEET_SECRET_NAME\" not set"}, - {"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .deb, .rpm, .tar.gz, .sh, or .ps1."}, + {"testdata/gitops/team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1."}, // commenting out, results in the process getting killed on CI and on some machines // {"testdata/gitops/team_software_installer_too_large.yml", "The maximum file size is 3 GB"}, {"testdata/gitops/team_software_installer_valid.yml", ""}, @@ -65,6 +65,8 @@ func TestGitOpsTeamSoftwareInstallers(t *testing.T) { {"testdata/gitops/team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"}, {"testdata/gitops/team_setup_software_invalid_software_package.yml", "no_such_software.yml\" does not exist for that team"}, {"testdata/gitops/team_setup_software_invalid_vpp_app.yml", "\"no_such_app\" does not exist for that team"}, + {"testdata/gitops/team_software_installer_valid_ipa.yml", ""}, + {"testdata/gitops/team_software_installer_subdir_ipa.yml", ""}, } for _, c := range cases { c.file = filepath.Join("../../fleetctl", c.file) @@ -127,8 +129,8 @@ func TestGitOpsTeamSoftwareInstallers(t *testing.T) { } return ret, nil } - ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint]*fleet.ExistingSoftwareInstaller, error) { - return map[uint]*fleet.ExistingSoftwareInstaller{}, nil + ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { + return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { return []uint{}, nil @@ -156,11 +158,14 @@ func TestGitOpsTeamSoftwareInstallersQueryEnv(t *testing.T) { } return nil } + ds.BatchSetInHouseAppsInstallersFunc = func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + return nil + } ds.GetSoftwareInstallersFunc = func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { return nil, nil } - ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint]*fleet.ExistingSoftwareInstaller, error) { - return map[uint]*fleet.ExistingSoftwareInstaller{}, nil + ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { + return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { return []uint{}, nil @@ -295,7 +300,7 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { wantErr string }{ {"testdata/gitops/no_team_software_installer_not_found.yml", "Please make sure that URLs are reachable from your Fleet server."}, - {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .deb, .rpm, .tar.gz, .sh, or .ps1."}, + {"testdata/gitops/no_team_software_installer_unsupported.yml", "The file should be .pkg, .msi, .exe, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1."}, // commenting out, results in the process getting killed on CI and on some machines // {"testdata/gitops/no_team_software_installer_too_large.yml", "The maximum file size is 3 GB"}, {"testdata/gitops/no_team_software_installer_valid.yml", ""}, @@ -320,6 +325,8 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { {"testdata/gitops/no_team_setup_software_invalid_script.yml", "no_such_script.sh: no such file"}, {"testdata/gitops/no_team_setup_software_invalid_software_package.yml", "no_such_software.yml\" does not exist for that team"}, {"testdata/gitops/no_team_setup_software_invalid_vpp_app.yml", "\"no_such_app\" does not exist for that team"}, + {"testdata/gitops/no_team_software_installer_valid_ipa.yml", ""}, + {"testdata/gitops/no_team_software_installer_subdir_ipa.yml", ""}, } for _, c := range cases { c.noTeamFile = filepath.Join("../../fleetctl", c.noTeamFile) @@ -380,8 +387,8 @@ func TestGitOpsNoTeamSoftwareInstallers(t *testing.T) { } return ret, nil } - ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint]*fleet.ExistingSoftwareInstaller, error) { - return map[uint]*fleet.ExistingSoftwareInstaller{}, nil + ds.GetTeamsWithInstallerByHashFunc = func(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { + return map[uint][]*fleet.ExistingSoftwareInstaller{}, nil } ds.GetSoftwareCategoryIDsFunc = func(ctx context.Context, names []string) ([]uint, error) { return []uint{}, nil diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index ec2e875fad..18762fd579 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -1645,7 +1645,7 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f if err != nil { if errors.Is(err, file.ErrUnsupportedType) { return "", &fleet.BadRequestError{ - Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe, .deb, .rpm, .tar.gz, .sh, or .ps1.", + Message: "Couldn't edit software. File type not supported. The file should be .pkg, .msi, .exe, .deb, .rpm, .tar.gz, .sh, .ipa or .ps1.", InternalErr: ctxerr.Wrap(ctx, err, "extracting metadata from installer"), } } @@ -1687,7 +1687,7 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f } // Software edits validate non-empty scripts later, so set failOnBlankScript to false - if payload.InstallScript == "" && failOnBlankScript { + if payload.InstallScript == "" && failOnBlankScript && payload.Extension != "ipa" { return "", &fleet.BadRequestError{ Message: fmt.Sprintf("Couldn't add. Install script is required for .%s packages.", strings.ToLower(payload.Extension)), } @@ -1700,28 +1700,35 @@ func (svc *Service) addMetadataToSoftwarePayload(ctx context.Context, payload *f payload.UninstallScript = file.UninstallMsiWithUpgradeCodeScript } } - if payload.UninstallScript == "" && failOnBlankScript { + if payload.UninstallScript == "" && failOnBlankScript && payload.Extension != "ipa" { return "", &fleet.BadRequestError{ Message: fmt.Sprintf("Couldn't add. Uninstall script is required for .%s packages.", strings.ToLower(payload.Extension)), } } - if payload.BundleIdentifier != "" { - payload.Source = "apps" - } else { - source, err := fleet.SofwareInstallerSourceFromExtensionAndName(meta.Extension, meta.Name) - if err != nil { - return "", ctxerr.Wrap(ctx, err, "determining source from extension and name") - } - payload.Source = source - } - platform, err := fleet.SoftwareInstallerPlatformFromExtension(meta.Extension) if err != nil { return "", ctxerr.Wrap(ctx, err, "determining platform from extension") } payload.Platform = platform + switch { + case payload.Extension == "ipa": + if payload.Platform == "ipados" { + payload.Source = "ipados_apps" + } else { + payload.Source = "ios_apps" + } + case payload.BundleIdentifier != "": + payload.Source = "apps" + default: + source, err := fleet.SofwareInstallerSourceFromExtensionAndName(meta.Extension, meta.Name) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "determining source from extension and name") + } + payload.Source = source + } + return meta.Extension, nil } @@ -1944,6 +1951,11 @@ func (svc *Service) softwareBatchUpload( ) { var batchErr error + // TODO: this might be a little drastic to drop back to Background context, + // consider using ctx.WithoutCancel to keep all but the cancellation of the + // parent: https://pkg.go.dev/context#WithoutCancel + // e.g. for telemetry and such. + // We do not use the request ctx on purpose because this method runs in the background. ctx := context.Background() @@ -2023,9 +2035,23 @@ func (svc *Service) softwareBatchUpload( var g errgroup.Group g.SetLimit(1) // TODO: consider whether we can increase this limit, see https://github.com/fleetdm/fleet/issues/22704#issuecomment-2397407837 - // critical to avoid data race, the slice is pre-allocated and each + + // the reason for this struct with extra installers support is that: + // - ih-house apps match multiple installers to a single source installer + // payload (because an .ipa creates entries for iOS and iPadOS) + // - the for loop over each entry in the payload is executed in a goroutine + // that can only write to its pre-allocated index in the installers slice, so + // any extra installer for a given payload must be part of a single value + // inserted in that slice. + type installerPayloadWithExtras struct { + *fleet.UploadSoftwareInstallerPayload + ExtraInstallers []*fleet.UploadSoftwareInstallerPayload + } + + // critical to avoid data race, the slices are pre-allocated and each // goroutine only writes to its index. - installers := make([]*fleet.UploadSoftwareInstallerPayload, len(payloads)) + installers := make([]*installerPayloadWithExtras, len(payloads)) + toBeClosedTFRs := make([]*fleet.TempFileReader, len(payloads)) for i, p := range payloads { i, p := i, p @@ -2033,8 +2059,8 @@ func (svc *Service) softwareBatchUpload( g.Go(func() error { // NOTE: cannot defer tfr.Close() here because the reader needs to be // available after the goroutine completes. Instead, all temp file - // readers will have their Close deferred after the join/wait of - // goroutines. + // readers are collected in toBeClosedTFRs and will have their Close + // deferred after the join/wait of goroutines. installer := &fleet.UploadSoftwareInstallerPayload{ TeamID: teamID, InstallScript: p.InstallScript, @@ -2051,6 +2077,8 @@ func (svc *Service) softwareBatchUpload( Categories: p.Categories, } + var extraInstallers []*fleet.UploadSoftwareInstallerPayload + p.Categories = server.RemoveDuplicatesFromSlice(p.Categories) catIDs, err := svc.ds.GetSoftwareCategoryIDs(ctx, p.Categories) if err != nil { @@ -2077,12 +2105,11 @@ func (svc *Service) softwareBatchUpload( tmID = *teamID } - foundInstaller, ok := teamIDs[tmID] - + foundInstallers, ok := teamIDs[tmID] switch { case ok: // Perfect match: existing installer on the same team - installer.StorageID = p.SHA256 + foundInstaller := foundInstallers[0] if foundInstaller.Extension == "exe" || foundInstaller.Extension == "tar.gz" { if p.InstallScript == "" { @@ -2093,18 +2120,19 @@ func (svc *Service) softwareBatchUpload( return fmt.Errorf("Couldn't edit. Uninstall script is required for .%s packages.", foundInstaller.Extension) } } - installer.Extension = foundInstaller.Extension - installer.Filename = foundInstaller.Filename - installer.Version = foundInstaller.Version - installer.Platform = foundInstaller.Platform - installer.Source = foundInstaller.Source - if foundInstaller.BundleIdentifier != nil { - installer.BundleIdentifier = *foundInstaller.BundleIdentifier + + // make a copy of the installer without filled fields in case we add + // extra installers + extraInstallerBase := *installer + fillSoftwareInstallerPayloadFromExisting(installer, foundInstaller, p.SHA256) + for _, extraInstaller := range foundInstallers[1:] { + extraPayload := extraInstallerBase + fillSoftwareInstallerPayloadFromExisting(&extraPayload, extraInstaller, p.SHA256) + extraInstallers = append(extraInstallers, &extraPayload) } - installer.Title = foundInstaller.Title - installer.PackageIDs = foundInstaller.PackageIDs + case !ok && len(teamIDs) > 0: - // Installer exists, but for another team. We should copy it over to this team + // Installer(s) exists, but for another team. We should copy it over to this team // (if we have access to the other team). user, err := svc.ds.UserByID(ctx, userID) if err != nil { @@ -2113,7 +2141,7 @@ func (svc *Service) softwareBatchUpload( userctx := viewer.NewContext(ctx, viewer.Viewer{User: user}) - for tmID, i := range teamIDs { + for tmID, teamInstallers := range teamIDs { // use the first one to which this user has access; the specific one shouldn't // matter because they're all the same installer bytes var tmIDPtr *uint @@ -2124,7 +2152,8 @@ func (svc *Service) softwareBatchUpload( continue } - if i.Extension == "exe" { + teamInstaller := teamInstallers[0] + if teamInstaller.Extension == "exe" { if p.InstallScript == "" { return errors.New("Couldn't edit. Install script is required for .exe packages.") } @@ -2134,17 +2163,16 @@ func (svc *Service) softwareBatchUpload( } } - installer.Extension = i.Extension - installer.Filename = i.Filename - installer.Version = i.Version - installer.Platform = i.Platform - installer.Source = i.Source - if i.BundleIdentifier != nil { - installer.BundleIdentifier = *i.BundleIdentifier + // make a copy of the installer without filled fields in case we add + // extra installers + extraInstallerBase := *installer + fillSoftwareInstallerPayloadFromExisting(installer, teamInstaller, p.SHA256) + for _, extraInstaller := range teamInstallers[1:] { + extraPayload := extraInstallerBase + fillSoftwareInstallerPayloadFromExisting(&extraPayload, extraInstaller, p.SHA256) + extraInstallers = append(extraInstallers, &extraPayload) } - installer.Title = i.Title - installer.StorageID = p.SHA256 - installer.PackageIDs = i.PackageIDs + break } } @@ -2170,17 +2198,25 @@ func (svc *Service) softwareBatchUpload( } installer.InstallerFile = tfr + toBeClosedTFRs[i] = tfr + filename = maintained_apps.FilenameFromResponse(resp) installer.Filename = filename - // For script packages (.sh and .ps1), clear unsupported fields early. - // Determine extension from filename to validate before metadata extraction. + // For script packages (.sh and .ps1) and in-house apps (.ipa), clear + // unsupported fields early. Determine extension from filename to + // validate before metadata extraction. ext := strings.ToLower(filepath.Ext(filename)) ext = strings.TrimPrefix(ext, ".") if fleet.IsScriptPackage(ext) { installer.PostInstallScript = "" installer.UninstallScript = "" installer.PreInstallQuery = "" + } else if ext == "ipa" { + installer.InstallScript = "" + installer.PostInstallScript = "" + installer.UninstallScript = "" + installer.PreInstallQuery = "" } } @@ -2244,7 +2280,6 @@ func (svc *Service) softwareBatchUpload( if installer.FleetMaintainedAppID == nil && installer.InstallerFile != nil { ext, err = svc.addMetadataToSoftwarePayload(ctx, installer, true) if err != nil { - _ = installer.InstallerFile.Close() // closing the temp file here since it will not be available after the goroutine completes return err } @@ -2254,14 +2289,17 @@ func (svc *Service) softwareBatchUpload( } } - // For script packages (.sh and .ps1), clear unsupported fields - // The file contents become the install script, so post_install_script, - // uninstall_script, and pre_install_query are not supported. - if fleet.IsScriptPackage(installer.Extension) { + // For script packages (.sh and .ps1) and in-house apps (.ipa), clear + // unsupported fields. For script packages, the file contents become the + // install script, so post_install_script, uninstall_script, and + // pre_install_query are not supported. + switch { + case fleet.IsScriptPackage(installer.Extension): installer.PostInstallScript = "" installer.UninstallScript = "" installer.PreInstallQuery = "" - } else if installer.Extension != "exe" { + + case installer.Extension != "exe": // custom scripts only for exe installers and non-script packages if installer.InstallScript == "" { installer.InstallScript = file.GetInstallScript(installer.Extension) @@ -2270,6 +2308,12 @@ func (svc *Service) softwareBatchUpload( if installer.UninstallScript == "" { installer.UninstallScript = file.GetUninstallScript(installer.Extension) } + + case installer.Extension == "ipa": + installer.PostInstallScript = "" + installer.UninstallScript = "" + installer.PreInstallQuery = "" + installer.InstallScript = "" } // Update $PACKAGE_ID/$UPGRADE_CODE in uninstall script @@ -2290,7 +2334,24 @@ func (svc *Service) softwareBatchUpload( installer.Title = installer.Filename } - installers[i] = installer + // if this is an .ipa and there is no extra installer, create it here + if installer.Extension == "ipa" && len(extraInstallers) == 0 { + extraPayload := *installer + switch installer.Platform { + case string(fleet.IOSPlatform): + extraPayload.Platform = string(fleet.IPadOSPlatform) + extraPayload.Source = "ipados_apps" + case string(fleet.IPadOSPlatform): + extraPayload.Platform = string(fleet.IOSPlatform) + extraPayload.Source = "ios_apps" + } + extraInstallers = append(extraInstallers, &extraPayload) + } + + installers[i] = &installerPayloadWithExtras{ + UploadSoftwareInstallerPayload: installer, + ExtraInstallers: extraInstallers, + } return nil }) @@ -2299,9 +2360,9 @@ func (svc *Service) softwareBatchUpload( waitErr := g.Wait() // defer close for any valid temp file reader - for _, payload := range installers { - if payload != nil && payload.InstallerFile != nil { - defer payload.InstallerFile.Close() + for _, tfr := range toBeClosedTFRs { + if tfr != nil { + defer tfr.Close() } } @@ -2315,22 +2376,49 @@ func (svc *Service) softwareBatchUpload( return } - for _, payload := range installers { + var inHouseInstallers, softwareInstallers []*fleet.UploadSoftwareInstallerPayload + for _, payloadWithExtras := range installers { + payload := payloadWithExtras.UploadSoftwareInstallerPayload if err := svc.storeSoftware(ctx, payload); err != nil { batchErr = fmt.Errorf("storing software installer %q: %w", payload.Filename, err) return } + if payload.Extension == "ipa" { + inHouseInstallers = append(inHouseInstallers, payload) + inHouseInstallers = append(inHouseInstallers, payloadWithExtras.ExtraInstallers...) + } else { + softwareInstallers = append(softwareInstallers, payload) + softwareInstallers = append(softwareInstallers, payloadWithExtras.ExtraInstallers...) + } } - if err := svc.ds.BatchSetSoftwareInstallers(ctx, teamID, installers); err != nil { + if err := svc.ds.BatchSetSoftwareInstallers(ctx, teamID, softwareInstallers); err != nil { batchErr = fmt.Errorf("batch set software installers: %w", err) return } + if err := svc.ds.BatchSetInHouseAppsInstallers(ctx, teamID, inHouseInstallers); err != nil { + batchErr = fmt.Errorf("batch set in-house apps installers: %w", err) + return + } // Note: per @noahtalerman we don't want activity items for CLI actions // anymore, so that's intentionally skipped. } +func fillSoftwareInstallerPayloadFromExisting(payload *fleet.UploadSoftwareInstallerPayload, existing *fleet.ExistingSoftwareInstaller, sha256Hash string) { + payload.Extension = existing.Extension + payload.Filename = existing.Filename + payload.Version = existing.Version + payload.Platform = existing.Platform + payload.Source = existing.Source + if existing.BundleIdentifier != nil { + payload.BundleIdentifier = *existing.BundleIdentifier + } + payload.Title = existing.Title + payload.StorageID = sha256Hash + payload.PackageIDs = existing.PackageIDs +} + func (svc *Service) GetBatchSetSoftwareInstallersResult(ctx context.Context, tmName string, requestUUID string, dryRun bool) (string, string, []fleet.SoftwarePackageResponse, error) { // We've already authorized in the POST /api/latest/fleet/software/batch, // but adding it here so we don't need to worry about a special case endpoint. diff --git a/server/datastore/mysql/in_house_apps.go b/server/datastore/mysql/in_house_apps.go index 799c6ee4f4..471a1c4a0d 100644 --- a/server/datastore/mysql/in_house_apps.go +++ b/server/datastore/mysql/in_house_apps.go @@ -196,7 +196,8 @@ SELECT iha.created_at AS uploaded_at, st.bundle_identifier AS bundle_identifier, COALESCE(st.name, '') AS software_title, - iha.self_service + iha.self_service, + iha.url FROM in_house_apps iha JOIN software_titles st ON st.id = iha.title_id @@ -314,13 +315,13 @@ func (ds *Datastore) RemovePendingInHouseAppInstalls(ctx context.Context, inHous } var installs []ipaInstall err := sqlx.SelectContext(ctx, ds.reader(ctx), &installs, ` - SELECT - host_id, - command_uuid - FROM - host_in_house_software_installs - WHERE - in_house_app_id = ? AND + SELECT + host_id, + command_uuid + FROM + host_in_house_software_installs + WHERE + in_house_app_id = ? AND canceled = 0 AND verification_at IS NULL AND verification_failed_at IS NULL @@ -367,16 +368,21 @@ upcoming AS ( ), -- select most recent past activities for each host +-- NOTE if you change this logic make sure to change inHouseAppHostStatusNamedQuery accordingly past AS ( SELECT hihsi.host_id, CASE - WHEN ncr.status = :mdm_status_acknowledged THEN + WHEN hihsi.verification_at IS NOT NULL THEN :software_status_installed + WHEN hihsi.verification_failed_at IS NOT NULL THEN + :software_status_failed WHEN ncr.status = :mdm_status_error OR ncr.status = :mdm_status_format_error THEN :software_status_failed + WHEN ncr.status = :mdm_status_acknowledged THEN + :software_status_pending ELSE - NULL -- either pending or not installed + NULL -- either pending or not installed via in-house App END AS status FROM host_in_house_software_installs hihsi @@ -684,3 +690,616 @@ WHERE return user, act, nil } + +func (ds *Datastore) BatchSetInHouseAppsInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + const upsertSoftwareTitles = ` +INSERT INTO software_titles + (name, source, extension_for, bundle_identifier) +VALUES + %s +ON DUPLICATE KEY UPDATE + name = VALUES(name), + source = VALUES(source), + extension_for = VALUES(extension_for), + bundle_identifier = VALUES(bundle_identifier) +` + + const loadSoftwareTitles = ` +SELECT + id +FROM + software_titles +WHERE (unique_identifier, source, extension_for) IN (%s) +` + + const cancelAllPendingInHouseInstalls = ` +UPDATE + host_in_house_software_installs +SET + canceled = 1 +WHERE + verification_at IS NULL AND + verification_failed_at IS NULL AND + in_house_app_id IN ( + SELECT id FROM in_house_apps WHERE global_or_team_id = ? + ) +` + + const cancelAllPendingInHouseNanoCmds = ` +UPDATE + nano_enrollment_queue +SET + active = 0 +WHERE + command_uuid IN ( + SELECT command_uuid + FROM host_in_house_software_installs hihsi + INNER JOIN in_house_apps iha ON hihsi.in_house_app_id = iha.id + WHERE + hihsi.verification_at IS NULL AND + hihsi.verification_failed_at IS NULL AND + iha.global_or_team_id = ? + ) +` + const loadAffectedHostsPendingInHouseInstallsUA = ` + SELECT + DISTINCT host_id + FROM + upcoming_activities ua + INNER JOIN in_house_app_upcoming_activities ihua + ON ua.id = ihua.upcoming_activity_id + WHERE + ua.activity_type = 'in_house_app_install' AND + ua.activated_at IS NOT NULL AND + ihua.in_house_app_id IN ( + SELECT id FROM in_house_apps WHERE global_or_team_id = ? + ) +` + + const deleteAllPendingInHouseInstallsUA = ` + DELETE FROM upcoming_activities + USING upcoming_activities + INNER JOIN in_house_app_upcoming_activities ihua + ON upcoming_activities.id = ihua.upcoming_activity_id + WHERE + activity_type = 'in_house_app_install' AND + ihua.in_house_app_id IN ( + SELECT id FROM in_house_apps WHERE global_or_team_id = ? + ) +` + const markAllInHouseInstallsAsRemoved = ` + UPDATE host_in_house_software_installs SET removed = TRUE + WHERE in_house_app_id IN ( + SELECT id FROM in_house_apps WHERE global_or_team_id = ? + ) +` + + const deleteAllInHouseInstallersInTeam = ` +DELETE FROM + in_house_apps +WHERE + global_or_team_id = ? +` + + const cancelPendingInHouseInstallsNotInList = ` +UPDATE + host_in_house_software_installs +SET + canceled = 1 +WHERE + verification_at IS NULL AND + verification_failed_at IS NULL AND + in_house_app_id IN ( + SELECT id FROM in_house_apps WHERE global_or_team_id = ? AND title_id NOT IN (?) + ) +` + + const cancelPendingInHouseNanoCmdsNotInList = ` +UPDATE + nano_enrollment_queue +SET + active = 0 +WHERE + command_uuid IN ( + SELECT command_uuid + FROM host_in_house_software_installs hihsi + INNER JOIN in_house_apps iha ON hihsi.in_house_app_id = iha.id + WHERE + hihsi.verification_at IS NULL AND + hihsi.verification_failed_at IS NULL AND + iha.global_or_team_id = ? AND + iha.title_id NOT IN (?) + ) +` + + const loadAffectedHostsPendingInHouseInstallsNotInListUA = ` + SELECT + DISTINCT host_id + FROM + upcoming_activities ua + INNER JOIN in_house_app_upcoming_activities ihua + ON ua.id = ihua.upcoming_activity_id + WHERE + ua.activity_type = 'in_house_app_install' AND + ua.activated_at IS NOT NULL AND + ihua.in_house_app_id IN ( + SELECT id FROM in_house_apps WHERE global_or_team_id = ? AND title_id NOT IN (?) + ) +` + + const deletePendingInHouseInstallsNotInListUA = ` + DELETE FROM upcoming_activities + USING upcoming_activities + INNER JOIN in_house_app_upcoming_activities ihua + ON upcoming_activities.id = ihua.upcoming_activity_id + WHERE + activity_type = 'in_house_app_install' AND + ihua.in_house_app_id IN ( + SELECT id FROM in_house_apps WHERE global_or_team_id = ? AND title_id NOT IN (?) + ) +` + + const markInHouseInstallsNotInListAsRemoved = ` + UPDATE host_in_house_software_installs SET removed = TRUE + WHERE in_house_app_id IN ( + SELECT id FROM in_house_apps WHERE global_or_team_id = ? AND title_id NOT IN (?) + ) +` + + const deleteInHouseInstallersNotInList = ` +DELETE FROM + in_house_apps +WHERE + global_or_team_id = ? AND + title_id NOT IN (?) +` + + const checkExistingInstaller = ` +SELECT + id, + storage_id != ? is_package_modified +FROM + in_house_apps +WHERE + global_or_team_id = ? AND + title_id IN (SELECT id FROM software_titles WHERE unique_identifier = ? AND source = ? AND extension_for = '') +` + + const insertNewOrEditedInstaller = ` +INSERT INTO in_house_apps ( + title_id, + team_id, + global_or_team_id, + filename, + version, + storage_id, + platform, + bundle_identifier, + self_service, + url +) VALUES ( + (SELECT id FROM software_titles WHERE unique_identifier = ? AND source = ? AND extension_for = ''), + ?, ?, ?, ?, ?, ?, ?, ?, ? +) +ON DUPLICATE KEY UPDATE + filename = VALUES(filename), + version = VALUES(version), + storage_id = VALUES(storage_id), + platform = VALUES(platform), + bundle_identifier = VALUES(bundle_identifier), + self_service = VALUES(self_service), + url = VALUES(url) +` + + const loadInHouseInstallerID = ` +SELECT + id +FROM + in_house_apps +WHERE + -- this is guaranteed to select a single in-house installer, due to unique index + global_or_team_id = ? AND + filename = ? AND + platform = ? +` + + const deleteInHouseLabelsNotInList = ` +DELETE FROM + in_house_app_labels +WHERE + in_house_app_id = ? AND + label_id NOT IN (?) +` + + const deleteAllInHouseLabels = ` +DELETE FROM + in_house_app_labels +WHERE + in_house_app_id = ? +` + + const upsertInHouseLabels = ` +INSERT INTO + in_house_app_labels ( + in_house_app_id, + label_id, + exclude + ) +VALUES + %s +ON DUPLICATE KEY UPDATE + exclude = VALUES(exclude) +` + + const loadExistingInHouseLabels = ` +SELECT + label_id, + exclude +FROM + in_house_app_labels +WHERE + in_house_app_id = ? +` + + // use a team id of 0 if no-team + var globalOrTeamID uint + if tmID != nil { + globalOrTeamID = *tmID + } + + // NOTE: at the time of implementation, in-house apps do not support install + // during setup, automatic install (via policies), categories, and + // uninstalls, so the related validations and updates that are done in + // BatchSetSoftwareInstallers are removed here. + + var activateAffectedHostIDs []uint + + err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + // if no installers are provided, just delete whatever was in the table + if len(installers) == 0 { + if _, err := tx.ExecContext(ctx, cancelAllPendingInHouseInstalls, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "cancel all pending host in-house install records") + } + if _, err := tx.ExecContext(ctx, cancelAllPendingInHouseNanoCmds, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "cancel all pending in-house nano commands") + } + + var affectedHostIDs []uint + if err := sqlx.SelectContext(ctx, tx, &affectedHostIDs, + loadAffectedHostsPendingInHouseInstallsUA, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "load affected hosts for upcoming in-house installs") + } + activateAffectedHostIDs = affectedHostIDs + + if _, err := tx.ExecContext(ctx, deleteAllPendingInHouseInstallsUA, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "delete all upcoming pending in-house install records") + } + + if _, err := tx.ExecContext(ctx, markAllInHouseInstallsAsRemoved, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "mark all host in-house installs as removed") + } + + if _, err := tx.ExecContext(ctx, deleteAllInHouseInstallersInTeam, globalOrTeamID); err != nil { + return ctxerr.Wrap(ctx, err, "delete obsolete in-house installers") + } + + return nil + } + + var args []any + for _, installer := range installers { + args = append( + args, + strings.TrimSuffix(installer.Filename, ".ipa"), + installer.Source, + "", + func() *string { + if strings.TrimSpace(installer.BundleIdentifier) != "" { + return &installer.BundleIdentifier + } + return nil + }(), + ) + } + + values := strings.TrimSuffix(strings.Repeat("(?,?,?,?),", len(installers)), ",") + if _, err := tx.ExecContext(ctx, fmt.Sprintf(upsertSoftwareTitles, values), args...); err != nil { + return ctxerr.Wrap(ctx, err, "insert new/edited software titles") + } + + var titleIDs []uint + args = []any{} + for _, installer := range installers { + args = append( + args, + BundleIdentifierOrName(installer.BundleIdentifier, strings.TrimSuffix(installer.Filename, ".ipa")), + installer.Source, + "", + ) + } + values = strings.TrimSuffix(strings.Repeat("(?,?,?),", len(installers)), ",") + + if err := sqlx.SelectContext(ctx, tx, &titleIDs, fmt.Sprintf(loadSoftwareTitles, values), args...); err != nil { + return ctxerr.Wrap(ctx, err, "load existing titles") + } + + stmt, args, err := sqlx.In(cancelPendingInHouseInstallsNotInList, globalOrTeamID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to cancel pending in-house installs") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "cancel obsolete pending host in-house install records") + } + stmt, args, err = sqlx.In(cancelPendingInHouseNanoCmdsNotInList, globalOrTeamID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to cancel pending in-house nano commands") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "cancel obsolete pending host in-house install nano commands") + } + + stmt, args, err = sqlx.In(loadAffectedHostsPendingInHouseInstallsNotInListUA, globalOrTeamID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to load affected hosts for upcoming in-house installs") + } + var affectedHostIDs []uint + if err := sqlx.SelectContext(ctx, tx, &affectedHostIDs, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "load affected hosts for upcoming in-house installs") + } + activateAffectedHostIDs = affectedHostIDs + + stmt, args, err = sqlx.In(deletePendingInHouseInstallsNotInListUA, globalOrTeamID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to delete upcoming pending in-house installs") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "delete obsolete upcoming pending host in-house install records") + } + + stmt, args, err = sqlx.In(markInHouseInstallsNotInListAsRemoved, globalOrTeamID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to mark obsolete host in-house installs as removed") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "mark obsolete host in-house installs as removed") + } + + stmt, args, err = sqlx.In(deleteInHouseInstallersNotInList, globalOrTeamID, titleIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to delete obsolete in-house installers") + } + if _, err := tx.ExecContext(ctx, stmt, args...); err != nil { + return ctxerr.Wrap(ctx, err, "delete obsolete in-house installers") + } + + for _, installer := range installers { + if installer.ValidatedLabels == nil { + return ctxerr.Errorf(ctx, "labels have not been validated for in-house app with name %s", installer.Filename) + } + + wasUpdatedArgs := []any{ + // package update + installer.StorageID, + // WHERE clause + globalOrTeamID, + BundleIdentifierOrName(installer.BundleIdentifier, strings.TrimSuffix(installer.Filename, ".ipa")), + installer.Source, + } + + // pull existing installer state if it exists so we can diff for side effects post-update + type existingInstallerUpdateCheckResult struct { + InstallerID uint `db:"id"` + IsPackageModified bool `db:"is_package_modified"` + IsMetadataModified bool + } + var existing []existingInstallerUpdateCheckResult + err = sqlx.SelectContext(ctx, tx, &existing, checkExistingInstaller, wasUpdatedArgs...) + if err != nil { + return ctxerr.Wrapf(ctx, err, "checking for existing installer with name %q", installer.Filename) + } + + args := []any{ + BundleIdentifierOrName(installer.BundleIdentifier, strings.TrimSuffix(installer.Filename, ".ipa")), + installer.Source, + tmID, + globalOrTeamID, + installer.Filename, + installer.Version, + installer.StorageID, + installer.Platform, + installer.BundleIdentifier, + installer.SelfService, + installer.URL, + } + upsertQuery := insertNewOrEditedInstaller + if len(existing) > 0 && existing[0].IsPackageModified { // update uploaded_at for updated installer package + upsertQuery = fmt.Sprintf("%s, updated_at = NOW()", upsertQuery) + } + + if _, err := tx.ExecContext(ctx, upsertQuery, args...); err != nil { + return ctxerr.Wrapf(ctx, err, "insert new/edited in-house app with name %q", installer.Filename) + } + + // now that the software installer is created/updated, load its installer + // ID (cannot use res.LastInsertID due to the upsert statement, won't + // give the id in case of update) + var installerID uint + if err := sqlx.GetContext(ctx, tx, &installerID, loadInHouseInstallerID, globalOrTeamID, installer.Filename, installer.Platform); err != nil { + return ctxerr.Wrapf(ctx, err, "load id of new/edited in-house app with name %q", installer.Filename) + } + + // process the labels associated with that in-house installer + if len(installer.ValidatedLabels.ByName) == 0 { + // no label to apply, so just delete all existing labels if any + res, err := tx.ExecContext(ctx, deleteAllInHouseLabels, installerID) + if err != nil { + return ctxerr.Wrapf(ctx, err, "delete in-house labels for %s", installer.Filename) + } + + if n, _ := res.RowsAffected(); n > 0 && len(existing) > 0 { + // if it did delete a row, then the target changed so pending + // installs/uninstalls must be deleted + existing[0].IsMetadataModified = true + } + } else { + // there are new labels to apply, delete only the obsolete ones + labelIDs := make([]uint, 0, len(installer.ValidatedLabels.ByName)) + for _, lbl := range installer.ValidatedLabels.ByName { + labelIDs = append(labelIDs, lbl.LabelID) + } + stmt, args, err := sqlx.In(deleteInHouseLabelsNotInList, installerID, labelIDs) + if err != nil { + return ctxerr.Wrap(ctx, err, "build statement to delete in-house labels not in list") + } + + res, err := tx.ExecContext(ctx, stmt, args...) + if err != nil { + return ctxerr.Wrapf(ctx, err, "delete in-house labels not in list for %s", installer.Filename) + } + if n, _ := res.RowsAffected(); n > 0 && len(existing) > 0 { + // if it did delete a row, then the target changed so pending + // installs/uninstalls must be deleted + existing[0].IsMetadataModified = true + } + + excludeLabels := installer.ValidatedLabels.LabelScope == fleet.LabelScopeExcludeAny + if len(existing) > 0 && !existing[0].IsMetadataModified { + // load the remaining labels for that installer, so that we can detect + // if any label changed (if the counts differ, then labels did change, + // otherwise if the exclude bool changed, the target did change). + var existingLabels []struct { + LabelID uint `db:"label_id"` + Exclude bool `db:"exclude"` + } + if err := sqlx.SelectContext(ctx, tx, &existingLabels, loadExistingInHouseLabels, installerID); err != nil { + return ctxerr.Wrapf(ctx, err, "load existing labels for in-house with name %q", installer.Filename) + } + + if len(existingLabels) != len(labelIDs) { + existing[0].IsMetadataModified = true + } + if len(existingLabels) > 0 && existingLabels[0].Exclude != excludeLabels { + // same labels are provided, but the include <-> exclude changed + existing[0].IsMetadataModified = true + } + } + + // upsert the new labels now that obsolete ones have been deleted + var upsertLabelArgs []any + for _, lblID := range labelIDs { + upsertLabelArgs = append(upsertLabelArgs, installerID, lblID, excludeLabels) + } + upsertLabelValues := strings.TrimSuffix(strings.Repeat("(?,?,?),", len(installer.ValidatedLabels.ByName)), ",") + + _, err = tx.ExecContext(ctx, fmt.Sprintf(upsertInHouseLabels, upsertLabelValues), upsertLabelArgs...) + if err != nil { + return ctxerr.Wrapf(ctx, err, "insert new/edited labels for in-house with name %q", installer.Filename) + } + } + + // perform side effects if this was an update (related to pending install requests) + if len(existing) > 0 { + affectedHostIDs, err := ds.runInHouseUpdateSideEffectsInTransaction( + ctx, + tx, + existing[0].InstallerID, + existing[0].IsMetadataModified, + existing[0].IsPackageModified, + ) + if err != nil { + return ctxerr.Wrapf(ctx, err, "processing side-effects for in-house with name %q", installer.Filename) + } + activateAffectedHostIDs = append(activateAffectedHostIDs, affectedHostIDs...) + } + } + + return nil + }) + if err != nil { + return err + } + return ds.activateNextUpcomingActivityForBatchOfHosts(ctx, activateAffectedHostIDs) +} + +func (ds *Datastore) runInHouseUpdateSideEffectsInTransaction(ctx context.Context, tx sqlx.ExtContext, installerID uint, wasMetadataUpdated bool, wasPackageUpdated bool) (affectedHostIDs []uint, err error) { + if wasMetadataUpdated || wasPackageUpdated { // cancel pending installs + const cancelInHouseInstalls = ` +UPDATE + host_in_house_software_installs +SET + canceled = 1 +WHERE + verification_at IS NULL AND + verification_failed_at IS NULL AND + in_house_app_id = ? +` + _, err = tx.ExecContext(ctx, cancelInHouseInstalls, installerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "cancel pending host in-house installs") + } + + const cancelInHouseCmds = ` +UPDATE + nano_enrollment_queue +SET + active = 0 +WHERE + command_uuid IN ( + SELECT command_uuid + FROM host_in_house_software_installs + WHERE + verification_at IS NULL AND + verification_failed_at IS NULL AND + in_house_app_id = ? + ) +` + _, err = tx.ExecContext(ctx, cancelInHouseCmds, installerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "cancel pending host in-house commands") + } + + const loadAffectedHosts = ` +SELECT + DISTINCT host_id +FROM + upcoming_activities ua +INNER JOIN in_house_app_upcoming_activities ihua + ON ua.id = ihua.upcoming_activity_id +WHERE + ua.activity_type = 'in_house_app_install' AND + ua.activated_at IS NOT NULL AND + ihua.in_house_app_id = ? +` + if err := sqlx.SelectContext(ctx, tx, &affectedHostIDs, loadAffectedHosts, installerID); err != nil { + return nil, ctxerr.Wrap(ctx, err, "select affected host IDs for in-house installs") + } + + const deleteUpcomingInHouse = ` +DELETE FROM upcoming_activities +USING upcoming_activities +INNER JOIN in_house_app_upcoming_activities ihua + ON upcoming_activities.id = ihua.upcoming_activity_id +WHERE + activity_type = 'in_house_app_install' AND + ihua.in_house_app_id = ? +` + + _, err = tx.ExecContext(ctx, deleteUpcomingInHouse, installerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "delete upcoming host in-house installs") + } + } + + if wasPackageUpdated { // hide existing install counts + const markInHouseRemoved = ` +UPDATE host_in_house_software_installs SET removed = TRUE +WHERE in_house_app_id = ? +` + _, err := tx.ExecContext(ctx, markInHouseRemoved, installerID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "hide existing install counts") + } + } + + return affectedHostIDs, nil +} diff --git a/server/datastore/mysql/in_house_apps_test.go b/server/datastore/mysql/in_house_apps_test.go index 9d89cfb281..7a49e7d445 100644 --- a/server/datastore/mysql/in_house_apps_test.go +++ b/server/datastore/mysql/in_house_apps_test.go @@ -2,7 +2,9 @@ package mysql import ( "context" + "database/sql" "fmt" + "sort" "testing" "time" @@ -26,6 +28,9 @@ func TestInHouseApps(t *testing.T) { }{ {"TestInHouseAppsCrud", testInHouseAppsCrud}, {"MultipleTeams", testInHouseAppsMultipleTeams}, + {"BatchSetInHouseInstallers", testBatchSetInHouseInstallers}, + {"BatchSetInHouseInstallersScopedViaLabels", testBatchSetInHouseInstallersScopedViaLabels}, + {"EditDeleteInHouseInstallersActivateNextActivity", testEditDeleteInHouseInstallersActivateNextActivity}, {"Categories", testInHouseAppsCategories}, } for _, c := range cases { @@ -107,7 +112,7 @@ func testInHouseAppsCrud(t *testing.T, ds *Datastore) { // Install on multiple users with pending, success, failure createInHouseAppInstallRequest(t, ds, host1.ID, installerID, titleID, user1) cmdUUID2 := createInHouseAppInstallRequest(t, ds, host2.ID, installerID, titleID, user1) - createInHouseAppInstallResult(t, ds, host2, cmdUUID2, "Acknowledged") + createInHouseAppInstallResultVerified(t, ds, host2, cmdUUID2, "Acknowledged") cmdUUID3 := createInHouseAppInstallRequest(t, ds, host3.ID, installerID, titleID, user1) createInHouseAppInstallResult(t, ds, host3, cmdUUID3, "Error") @@ -331,14 +336,6 @@ func testInHouseAppsCategories(t *testing.T, ds *Datastore) { CategoryIDs: []uint{1, 2}, } - // Software categories are missing from test schema - ExecAdhocSQL(t, ds, func(tx sqlx.ExtContext) error { - _, err := tx.ExecContext(ctx, ` - INSERT INTO software_categories - VALUES (1,'Productivity'), (2,'Browsers'),(3,'Communication'),(4,'Developer tools')`) - return err - }) - // Add installers for both teams installerID, _, err := ds.MatchOrCreateSoftwareInstaller(ctx, &payload1) require.NoError(t, err) @@ -449,3 +446,1049 @@ func createInHouseAppInstallResultVerified(t *testing.T, ds *Datastore, host *fl return err }) } + +func testBatchSetInHouseInstallers(t *testing.T, ds *Datastore) { + ctx := context.Background() + t.Cleanup(func() { ds.testActivateSpecificNextActivities = nil }) + + // create a team + team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()}) + require.NoError(t, err) + + // create a couple hosts + host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now(), test.WithPlatform("ios")) + host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now(), test.WithPlatform("ios")) + err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host1.ID, host2.ID})) + require.NoError(t, err) + nanoEnroll(t, ds, host1, false) + nanoEnroll(t, ds, host2, false) + user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + assertTitlesAndApps := func(wantTitles []fleet.SoftwareTitleListResult, wantApps []fleet.InHouseAppPayload) { + tmFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}} + titles, _, _, err := ds.ListSoftwareTitles( + ctx, + fleet.SoftwareTitleListOptions{TeamID: &team.ID}, + tmFilter, + ) + require.NoError(t, err) + require.Len(t, titles, len(wantTitles)) + + sort.Slice(wantTitles, func(i, j int) bool { + l, r := wantTitles[i], wantTitles[j] + return l.Name < r.Name || l.Name == r.Name && l.Source < r.Source + }) + sort.Slice(titles, func(i, j int) bool { + l, r := titles[i], titles[j] + return l.Name < r.Name || l.Name == r.Name && l.Source < r.Source + }) + + titleIDs := make([]uint, len(wantTitles)) + for i, want := range wantTitles { + got := titles[i] + require.Equal(t, want.Name, got.Name) + require.Equal(t, want.Source, got.Source) + require.Equal(t, want.BundleIdentifier == nil, got.BundleIdentifier == nil) + if want.BundleIdentifier != nil { + require.Equal(t, *want.BundleIdentifier, *got.BundleIdentifier) + } + titleIDs[i] = got.ID + } + + sort.Slice(wantApps, func(i, j int) bool { + l, r := wantApps[i], wantApps[j] + return l.Filename < r.Filename || l.Filename == r.Filename && l.Platform < r.Platform + }) + require.Len(t, wantApps, len(titleIDs)) + + for i, want := range wantApps { + got, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, titleIDs[i]) + require.NoError(t, err) + require.Equal(t, want.Filename, got.Name) + require.Equal(t, want.Platform, got.Platform) + require.Equal(t, want.Version, got.Version) + require.Equal(t, want.StorageID, got.StorageID) + require.Equal(t, want.SelfService, got.SelfService) + require.Equal(t, want.BundleID, got.BundleIdentifier) + } + } + + // batch set with everything empty + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, nil) + require.NoError(t, err) + apps, err := ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Empty(t, apps) + assertTitlesAndApps(nil, nil) + + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{}) + require.NoError(t, err) + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Empty(t, apps) + assertTitlesAndApps(nil, nil) + + ipa1 := fleet.UploadSoftwareInstallerPayload{ + TeamID: &team.ID, + UserID: user1.ID, + Title: "ipa1", + Filename: "ipa1.ipa", + BundleIdentifier: "com.ipa1", + StorageID: "ipa1", + Extension: "ipa", + Version: "1.0.0", + SelfService: true, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + } + // the batch-upload handler would've generated both iOS and iPadOS entries + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ios_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ipados_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + }) + require.NoError(t, err) + + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, apps, 2) + require.NotNil(t, apps[0].TeamID) + require.Equal(t, team.ID, *apps[0].TeamID) + require.NotNil(t, apps[0].TitleID) + require.Equal(t, "https://example.com/1", apps[0].URL) + + assertTitlesAndApps([]fleet.SoftwareTitleListResult{ + {Name: ipa1.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa1")}, + {Name: ipa1.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa1")}, + }, []fleet.InHouseAppPayload{ + {Filename: ipa1.Filename, Platform: "ios", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + {Filename: ipa1.Filename, Platform: "ipados", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + }) + + // add a new installer + ipa1 installer + ipa2 := fleet.UploadSoftwareInstallerPayload{ + TeamID: &team.ID, + UserID: user1.ID, + Title: "ipa2", + Filename: "ipa2.ipa", + BundleIdentifier: "com.ipa2", + StorageID: "ipa2", + Extension: "ipa", + Version: "2.0.0", + SelfService: false, + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + } + + // the batch-upload handler would've generated both iOS and iPadOS entries + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ios_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ipados_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ios_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ipados_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + }) + require.NoError(t, err) + + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + sort.Slice(apps, func(i, j int) bool { + l, r := apps[i], apps[j] + return l.URL < r.URL + }) + require.Len(t, apps, 4) + require.NotNil(t, apps[0].TeamID) + require.Equal(t, team.ID, *apps[0].TeamID) + require.NotNil(t, apps[0].TitleID) + require.Equal(t, "https://example.com/1", apps[0].URL) + + assertTitlesAndApps([]fleet.SoftwareTitleListResult{ + {Name: ipa1.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa1")}, + {Name: ipa1.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa1")}, + {Name: ipa2.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa2")}, + {Name: ipa2.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa2")}, + }, []fleet.InHouseAppPayload{ + {Filename: ipa1.Filename, Platform: "ios", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + {Filename: ipa1.Filename, Platform: "ipados", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + {Filename: ipa2.Filename, Platform: "ios", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + {Filename: ipa2.Filename, Platform: "ipados", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + }) + + // rerun with no change + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ios_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ipados_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ios_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ipados_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + }) + require.NoError(t, err) + + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, apps, 4) + + assertTitlesAndApps([]fleet.SoftwareTitleListResult{ + {Name: ipa1.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa1")}, + {Name: ipa1.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa1")}, + {Name: ipa2.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa2")}, + {Name: ipa2.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa2")}, + }, []fleet.InHouseAppPayload{ + {Filename: ipa1.Filename, Platform: "ios", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + {Filename: ipa1.Filename, Platform: "ipados", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + {Filename: ipa2.Filename, Platform: "ios", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + {Filename: ipa2.Filename, Platform: "ipados", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + }) + + // change ipa2 self-service + ipa2.SelfService = !ipa2.SelfService + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ios_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ipados_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ios_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ipados_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + }) + require.NoError(t, err) + + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, apps, 4) + + assertTitlesAndApps([]fleet.SoftwareTitleListResult{ + {Name: ipa1.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa1")}, + {Name: ipa1.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa1")}, + {Name: ipa2.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa2")}, + {Name: ipa2.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa2")}, + }, []fleet.InHouseAppPayload{ + {Filename: ipa1.Filename, Platform: "ios", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + {Filename: ipa1.Filename, Platform: "ipados", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + {Filename: ipa2.Filename, Platform: "ios", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + {Filename: ipa2.Filename, Platform: "ipados", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + }) + + // remove ipa1 + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ios_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ipados_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + }) + require.NoError(t, err) + + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, apps, 2) + + var iosInHouseID2, iosTitleID2 uint + for _, app := range apps { + require.NotNil(t, app.TitleID) + meta, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, *app.TitleID) + require.NoError(t, err) + if meta.Platform == "ios" { + iosInHouseID2 = meta.InstallerID + iosTitleID2 = *app.TitleID + } + } + + assertTitlesAndApps([]fleet.SoftwareTitleListResult{ + {Name: ipa2.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa2")}, + {Name: ipa2.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa2")}, + }, []fleet.InHouseAppPayload{ + {Filename: ipa2.Filename, Platform: "ios", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + {Filename: ipa2.Filename, Platform: "ipados", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + }) + + // add pending and completed installs for ipa2 + completedCmd := createInHouseAppInstallRequest(t, ds, host1.ID, iosInHouseID2, iosTitleID2, user1) + createInHouseAppInstallRequest(t, ds, host2.ID, iosInHouseID2, iosTitleID2, user1) + createInHouseAppInstallResultVerified(t, ds, host1, completedCmd, "Acknowledged") + + summary, err := ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, iosInHouseID2) + require.NoError(t, err) + require.Equal(t, fleet.VPPAppStatusSummary{Installed: 1, Pending: 1}, *summary) + + // batch-set without changes, should not affect installs + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ios_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + { + StorageID: ipa2.StorageID, + Filename: ipa2.Filename, + Title: ipa2.Title, + Source: "ipados_apps", + Version: ipa2.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/2", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa2.BundleIdentifier, + SelfService: ipa2.SelfService, + }, + }) + require.NoError(t, err) + + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, apps, 2) + assertTitlesAndApps([]fleet.SoftwareTitleListResult{ + {Name: ipa2.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa2")}, + {Name: ipa2.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa2")}, + }, []fleet.InHouseAppPayload{ + {Filename: ipa2.Filename, Platform: "ios", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + {Filename: ipa2.Filename, Platform: "ipados", Version: ipa2.Version, StorageID: ipa2.StorageID, SelfService: ipa2.SelfService, BundleID: ipa2.BundleIdentifier}, + }) + + summary, err = ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, iosInHouseID2) + require.NoError(t, err) + require.Equal(t, fleet.VPPAppStatusSummary{Installed: 1, Pending: 1}, *summary) + + // remove ipa2 and add ipa1 + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ios_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ipados_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + }) + require.NoError(t, err) + + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, apps, 2) + + var iosInHouseID1, iosTitleID1 uint + for _, app := range apps { + require.NotNil(t, app.TitleID) + meta, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, &team.ID, *app.TitleID) + require.NoError(t, err) + if meta.Platform == "ios" { + iosInHouseID1 = meta.InstallerID + iosTitleID1 = *app.TitleID + } + } + + assertTitlesAndApps([]fleet.SoftwareTitleListResult{ + {Name: ipa1.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa1")}, + {Name: ipa1.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa1")}, + }, []fleet.InHouseAppPayload{ + {Filename: ipa1.Filename, Platform: "ios", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + {Filename: ipa1.Filename, Platform: "ipados", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + }) + + // stats don't report anything about ipa2 anymore + summary, err = ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, iosInHouseID2) + require.NoError(t, err) + require.Equal(t, fleet.VPPAppStatusSummary{Installed: 0, Pending: 0}, *summary) + + pendingHost1, _, err := ds.ListHostUpcomingActivities(ctx, host1.ID, fleet.ListOptions{PerPage: 10}) + require.NoError(t, err) + require.Empty(t, pendingHost1) + pendingHost2, _, err := ds.ListHostUpcomingActivities(ctx, host2.ID, fleet.ListOptions{PerPage: 10}) + require.NoError(t, err) + require.Empty(t, pendingHost2) + + // add pending and completed installs for ipa1 + completedCmd = createInHouseAppInstallRequest(t, ds, host1.ID, iosInHouseID1, iosTitleID1, user1) + createInHouseAppInstallRequest(t, ds, host2.ID, iosInHouseID1, iosTitleID1, user1) + createInHouseAppInstallResultVerified(t, ds, host1, completedCmd, "Acknowledged") + + summary, err = ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, iosInHouseID1) + require.NoError(t, err) + require.Equal(t, fleet.VPPAppStatusSummary{Installed: 1, Pending: 1}, *summary) + + // update the storage ID of ipa1 (so it is a different installer binary) + ipa1.StorageID = "ipa1-new" + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ios_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ios", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + { + StorageID: ipa1.StorageID, + Filename: ipa1.Filename, + Title: ipa1.Title, + Source: "ipados_apps", + Version: ipa1.Version, + UserID: user1.ID, + Platform: "ipados", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: ipa1.BundleIdentifier, + SelfService: ipa1.SelfService, + }, + }) + require.NoError(t, err) + + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Len(t, apps, 2) + + assertTitlesAndApps([]fleet.SoftwareTitleListResult{ + {Name: ipa1.Title, Source: "ios_apps", BundleIdentifier: ptr.String("com.ipa1")}, + {Name: ipa1.Title, Source: "ipados_apps", BundleIdentifier: ptr.String("com.ipa1")}, + }, []fleet.InHouseAppPayload{ + {Filename: ipa1.Filename, Platform: "ios", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + {Filename: ipa1.Filename, Platform: "ipados", Version: ipa1.Version, StorageID: ipa1.StorageID, SelfService: ipa1.SelfService, BundleID: ipa1.BundleIdentifier}, + }) + + // stats don't report anything about ipa1 anymore (as if it was deleted) + summary, err = ds.GetSummaryHostInHouseAppInstalls(ctx, &team.ID, iosInHouseID1) + require.NoError(t, err) + require.Equal(t, fleet.VPPAppStatusSummary{Installed: 0, Pending: 0}, *summary) + + pendingHost1, _, err = ds.ListHostUpcomingActivities(ctx, host1.ID, fleet.ListOptions{PerPage: 10}) + require.NoError(t, err) + require.Empty(t, pendingHost1) + pendingHost2, _, err = ds.ListHostUpcomingActivities(ctx, host2.ID, fleet.ListOptions{PerPage: 10}) + require.NoError(t, err) + require.Empty(t, pendingHost2) + + // remove everything + err = ds.BatchSetInHouseAppsInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{}) + require.NoError(t, err) + apps, err = ds.GetSoftwareInstallers(ctx, team.ID) + require.NoError(t, err) + require.Empty(t, apps) + assertTitlesAndApps(nil, nil) +} + +func testBatchSetInHouseInstallersScopedViaLabels(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // create a host to have a pending install request + host := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now(), test.WithPlatform("ios")) + nanoEnroll(t, ds, host, false) + + // create a couple teams and a user + tm1, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "1"}) + require.NoError(t, err) + tm2, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "2"}) + require.NoError(t, err) + user := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + // create some installer payloads to be used by test cases + installers := make([]*fleet.UploadSoftwareInstallerPayload, 3) + for i := range installers { + installers[i] = &fleet.UploadSoftwareInstallerPayload{ + UserID: user.ID, + Title: "ipa" + fmt.Sprint(i), + Filename: fmt.Sprintf("ipa%d.ipa", i), + BundleIdentifier: "com.ipa" + fmt.Sprint(i), + StorageID: "ipa" + fmt.Sprint(i), + Extension: "ipa", + Version: "1.0.0", + URL: "https://example.com/" + fmt.Sprint(i), + Source: "ios_apps", + Platform: "ios", + } + } + + // create some labels to be used by test cases + labels := make([]*fleet.Label, 4) + for i := range labels { + lbl, err := ds.NewLabel(ctx, &fleet.Label{Name: "label" + fmt.Sprint(i)}) + require.NoError(t, err) + labels[i] = lbl + } + + type testPayload struct { + Installer *fleet.UploadSoftwareInstallerPayload + Labels []*fleet.Label + Exclude bool + ShouldCancelPending *bool // nil if the installer is new (could not have pending), otherwise true/false if it was edited + } + + // test scenarios - note that subtests must NOT be used as the sequence of + // tests matters - they cannot be run in isolation. + cases := []struct { + desc string + team *fleet.Team + payload []testPayload + }{ + { + desc: "empty payload", + payload: nil, + }, + { + desc: "no team, installer0, no label", + payload: []testPayload{ + {Installer: installers[0]}, + }, + }, + { + desc: "team 1, installer0, include label0", + team: tm1, + payload: []testPayload{ + {Installer: installers[0], Labels: []*fleet.Label{labels[0]}}, + }, + }, + { + desc: "no team, installer0 no change, add installer1 with exclude label1", + payload: []testPayload{ + {Installer: installers[0], ShouldCancelPending: ptr.Bool(false)}, + {Installer: installers[1], Labels: []*fleet.Label{labels[1]}, Exclude: true}, + }, + }, + { + desc: "no team, installer0 no change, installer1 change to include label1", + payload: []testPayload{ + {Installer: installers[0], ShouldCancelPending: ptr.Bool(false)}, + {Installer: installers[1], Labels: []*fleet.Label{labels[1]}, Exclude: false, ShouldCancelPending: ptr.Bool(true)}, + }, + }, + { + desc: "team 1, installer0, include label0 and add label1", + team: tm1, + payload: []testPayload{ + {Installer: installers[0], Labels: []*fleet.Label{labels[0], labels[1]}, ShouldCancelPending: ptr.Bool(true)}, + }, + }, + { + desc: "team 1, installer0, remove label0 and keep label1", + team: tm1, + payload: []testPayload{ + {Installer: installers[0], Labels: []*fleet.Label{labels[1]}, ShouldCancelPending: ptr.Bool(true)}, + }, + }, + { + desc: "team 1, installer0, switch to label0 and label2", + team: tm1, + payload: []testPayload{ + {Installer: installers[0], Labels: []*fleet.Label{labels[0], labels[2]}, ShouldCancelPending: ptr.Bool(true)}, + }, + }, + { + desc: "team 2, 3 installers, mix of labels", + team: tm2, + payload: []testPayload{ + {Installer: installers[0], Labels: []*fleet.Label{labels[0]}, Exclude: false}, + {Installer: installers[1], Labels: []*fleet.Label{labels[0], labels[1], labels[2]}, Exclude: true}, + {Installer: installers[2], Labels: []*fleet.Label{labels[1], labels[2]}, Exclude: false}, + }, + }, + { + desc: "team 1, installer0 no change and add installer2", + team: tm1, + payload: []testPayload{ + {Installer: installers[0], Labels: []*fleet.Label{labels[0], labels[2]}, ShouldCancelPending: ptr.Bool(false)}, + {Installer: installers[2]}, + }, + }, + { + desc: "team 1, installer0 switch to labels 1 and 3, installer2 no change", + team: tm1, + payload: []testPayload{ + {Installer: installers[0], Labels: []*fleet.Label{labels[1], labels[3]}, ShouldCancelPending: ptr.Bool(true)}, + {Installer: installers[2], ShouldCancelPending: ptr.Bool(false)}, + }, + }, + { + desc: "team 2, remove installer0, labels of install1 and no change installer2", + team: tm2, + payload: []testPayload{ + {Installer: installers[1], ShouldCancelPending: ptr.Bool(true)}, + {Installer: installers[2], Labels: []*fleet.Label{labels[1], labels[2]}, Exclude: false, ShouldCancelPending: ptr.Bool(false)}, + }, + }, + { + desc: "no team, remove all", + payload: []testPayload{}, + }, + } + for _, c := range cases { + t.Log("Running test case ", c.desc) + + var teamID *uint + var globalOrTeamID uint + if c.team != nil { + teamID = &c.team.ID + globalOrTeamID = c.team.ID + } + + // cleanup any existing install requests for the host + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + if _, err := q.ExecContext(ctx, `DELETE FROM upcoming_activities WHERE host_id = ?`, host.ID); err != nil { + return err + } + _, err := q.ExecContext(ctx, `DELETE FROM host_in_house_software_installs WHERE host_id = ?`, host.ID) + return err + }) + + installerIDs := make([]uint, len(c.payload)) + if len(c.payload) > 0 { + // create pending install requests for each updated installer, to see if + // it cancels it or not as expected. + err := ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(teamID, []uint{host.ID})) + require.NoError(t, err) + for i, payload := range c.payload { + if payload.ShouldCancelPending != nil { + // the installer must exist + var ihaID, titleID uint + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + err := sqlx.GetContext(ctx, q, &titleID, `SELECT id FROM software_titles WHERE name = ? AND source = ? AND extension_for = ''`, + payload.Installer.Title, payload.Installer.Source) + if err != nil { + return err + } + err = sqlx.GetContext(ctx, q, &ihaID, `SELECT id FROM in_house_apps WHERE global_or_team_id = ? + AND title_id = ?`, globalOrTeamID, titleID) + return err + }) + createInHouseAppInstallRequest(t, ds, host.ID, ihaID, titleID, user) + installerIDs[i] = ihaID + } + } + } + + // create the payload by copying the test one, so that the original installers + // structs are not modified + payload := make([]*fleet.UploadSoftwareInstallerPayload, len(c.payload)) + for i, p := range c.payload { + installer := *p.Installer + installer.ValidatedLabels = &fleet.LabelIdentsWithScope{LabelScope: fleet.LabelScopeIncludeAny} + if p.Exclude { + installer.ValidatedLabels.LabelScope = fleet.LabelScopeExcludeAny + } + byName := make(map[string]fleet.LabelIdent, len(p.Labels)) + for _, lbl := range p.Labels { + byName[lbl.Name] = fleet.LabelIdent{LabelName: lbl.Name, LabelID: lbl.ID} + } + installer.ValidatedLabels.ByName = byName + payload[i] = &installer + } + + err = ds.BatchSetInHouseAppsInstallers(ctx, teamID, payload) + require.NoError(t, err) + installers, err := ds.GetSoftwareInstallers(ctx, globalOrTeamID) + require.NoError(t, err) + require.Len(t, installers, len(c.payload)) + + // get the metadata for each installer to assert the batch did set the + // expected ones. + installersByFilename := make(map[string]*fleet.SoftwareInstaller, len(installers)) + for _, ins := range installers { + meta, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, teamID, *ins.TitleID) + require.NoError(t, err) + installersByFilename[meta.Name] = meta + } + + // validate that the inserted software is as expected + for i, payload := range c.payload { + meta, ok := installersByFilename[payload.Installer.Filename] + require.True(t, ok, "installer %s was not created", payload.Installer.Filename) + require.Equal(t, meta.SoftwareTitle, payload.Installer.Title) + + wantLabelIDs := make([]uint, len(payload.Labels)) + for j, lbl := range payload.Labels { + wantLabelIDs[j] = lbl.ID + } + if payload.Exclude { + require.Empty(t, meta.LabelsIncludeAny) + gotLabelIDs := make([]uint, len(meta.LabelsExcludeAny)) + for i, lbl := range meta.LabelsExcludeAny { + gotLabelIDs[i] = lbl.LabelID + } + require.ElementsMatch(t, wantLabelIDs, gotLabelIDs) + } else { + require.Empty(t, meta.LabelsExcludeAny) + gotLabelIDs := make([]uint, len(meta.LabelsIncludeAny)) + for j, lbl := range meta.LabelsIncludeAny { + gotLabelIDs[j] = lbl.LabelID + } + require.ElementsMatch(t, wantLabelIDs, gotLabelIDs) + } + + // check if it deleted pending installs or not + if payload.ShouldCancelPending != nil { + var exists bool + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + err := sqlx.GetContext(ctx, q, &exists, `SELECT 1 + FROM upcoming_activities ua + INNER JOIN in_house_app_upcoming_activities ihua + ON ua.id = ihua.upcoming_activity_id AND ua.activity_type = 'in_house_app_install' + WHERE ua.host_id = ? AND ihua.in_house_app_id = ?`, host.ID, installerIDs[i]) + if err == sql.ErrNoRows { + err = nil + } + return err + }) + if *payload.ShouldCancelPending { + require.False(t, exists, "pending install for installer %s was not cancelled but it should have been", payload.Installer.Filename) + } else { + require.True(t, exists, "pending install for installer %s was cancelled but it should not have been", payload.Installer.Filename) + } + } + } + } +} + +func testEditDeleteInHouseInstallersActivateNextActivity(t *testing.T, ds *Datastore) { + ctx := t.Context() + user := test.NewUser(t, ds, "Alice", "alice@example.com", true) + + // create a label + label, err := ds.NewLabel(ctx, &fleet.Label{Name: "A"}) + require.NoError(t, err) + + // create a few installers + err = ds.BatchSetInHouseAppsInstallers(ctx, nil, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: "ipa0", + Filename: "ipa0.ipa", + Title: "ipa0", + Source: "ios_apps", + Version: "1.0.0", + UserID: user.ID, + Platform: "ios", + URL: "https://example.com/0", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: "com.ipa0", + SelfService: false, + }, + { + StorageID: "ipa1", + Filename: "ipa1.ipa", + Title: "ipa1", + Source: "ios_apps", + Version: "1.0.0", + UserID: user.ID, + Platform: "ios", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: "com.ipa1", + SelfService: false, + }, + }) + require.NoError(t, err) + + installers, err := ds.GetSoftwareInstallers(ctx, 0) + require.NoError(t, err) + require.Len(t, installers, 2) + sort.Slice(installers, func(i, j int) bool { + return installers[i].URL < installers[j].URL + }) + ipa0, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, nil, *installers[0].TitleID) + require.NoError(t, err) + ipa1, err := ds.GetInHouseAppMetadataByTeamAndTitleID(ctx, nil, *installers[1].TitleID) + require.NoError(t, err) + + // create a few hosts + host1 := test.NewHost(t, ds, "host1", "1", "host1key", "host1uuid", time.Now(), test.WithPlatform("ios")) + host2 := test.NewHost(t, ds, "host2", "2", "host2key", "host2uuid", time.Now(), test.WithPlatform("ios")) + host3 := test.NewHost(t, ds, "host3", "3", "host3key", "host3uuid", time.Now(), test.WithPlatform("ios")) + nanoEnroll(t, ds, host1, false) + nanoEnroll(t, ds, host2, false) + nanoEnroll(t, ds, host3, false) + + // enqueue software installs on each host + host1Ipa0 := createInHouseAppInstallRequest(t, ds, host1.ID, ipa0.InstallerID, *installers[0].TitleID, user) + host1Ipa1 := createInHouseAppInstallRequest(t, ds, host1.ID, ipa1.InstallerID, *installers[1].TitleID, user) + // add a script exec as last activity for host1 + host1Script, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ + HostID: host1.ID, ScriptContents: "echo", UserID: &user.ID, SyncRequest: true, + }) + require.NoError(t, err) + host2Ipa0 := createInHouseAppInstallRequest(t, ds, host2.ID, ipa0.InstallerID, *installers[0].TitleID, user) + host2Ipa1 := createInHouseAppInstallRequest(t, ds, host2.ID, ipa1.InstallerID, *installers[1].TitleID, user) + // add a script exec as first activity for host3 + host3Script, err := ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ + HostID: host3.ID, ScriptContents: "echo", UserID: &user.ID, SyncRequest: true, + }) + require.NoError(t, err) + host3Ipa1 := createInHouseAppInstallRequest(t, ds, host3.ID, ipa1.InstallerID, *installers[1].TitleID, user) + + checkUpcomingActivities(t, ds, host1, host1Ipa0, host1Ipa1, host1Script.ExecutionID) + checkUpcomingActivities(t, ds, host2, host2Ipa0, host2Ipa1) + checkUpcomingActivities(t, ds, host3, host3Script.ExecutionID, host3Ipa1) + + // update installer ipa0 metadata (label condition) + err = ds.BatchSetInHouseAppsInstallers(ctx, nil, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: "ipa0", + Filename: "ipa0.ipa", + Title: "ipa0", + Source: "ios_apps", + Version: "1.0.0", + UserID: user.ID, + Platform: "ios", + URL: "https://example.com/0", + ValidatedLabels: &fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{label.Name: {LabelID: label.ID, LabelName: label.Name}}, + }, + BundleIdentifier: "com.ipa0", + SelfService: false, + }, + { + StorageID: "ipa1", + Filename: "ipa1.ipa", + Title: "ipa1", + Source: "ios_apps", + Version: "1.0.0", + UserID: user.ID, + Platform: "ios", + URL: "https://example.com/1", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + BundleIdentifier: "com.ipa1", + SelfService: false, + }, + }) + require.NoError(t, err) + + // installer ipa0 activities were deleted, next activity was activated + checkUpcomingActivities(t, ds, host1, host1Ipa1, host1Script.ExecutionID) + checkUpcomingActivities(t, ds, host2, host2Ipa1) + checkUpcomingActivities(t, ds, host3, host3Script.ExecutionID, host3Ipa1) + + // delete ipa1 + err = ds.BatchSetInHouseAppsInstallers(ctx, nil, []*fleet.UploadSoftwareInstallerPayload{ + { + StorageID: "ipa0", + Filename: "ipa0.ipa", + Title: "ipa0", + Source: "ios_apps", + Version: "1.0.0", + UserID: user.ID, + Platform: "ios", + URL: "https://example.com/0", + ValidatedLabels: &fleet.LabelIdentsWithScope{ + LabelScope: fleet.LabelScopeIncludeAny, + ByName: map[string]fleet.LabelIdent{label.Name: {LabelID: label.ID, LabelName: label.Name}}, + }, + BundleIdentifier: "com.ipa0", + SelfService: false, + }, + }) + require.NoError(t, err) + + // installer ipa1 activities were deleted, next activity was activated for host1 and host2 + checkUpcomingActivities(t, ds, host1, host1Script.ExecutionID) + checkUpcomingActivities(t, ds, host2) + checkUpcomingActivities(t, ds, host3, host3Script.ExecutionID) +} diff --git a/server/datastore/mysql/migrations/tables/20251111153133_AddUrlToInHouseApps.go b/server/datastore/mysql/migrations/tables/20251111153133_AddUrlToInHouseApps.go new file mode 100644 index 0000000000..9248b97513 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20251111153133_AddUrlToInHouseApps.go @@ -0,0 +1,25 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20251111153133, Down_20251111153133) +} + +func Up_20251111153133(tx *sql.Tx) error { + _, err := tx.Exec(` +ALTER TABLE in_house_apps + ADD COLUMN url varchar(4095) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' +`) + if err != nil { + return fmt.Errorf("failed to alter in_house_apps url: %w", err) + } + return nil +} + +func Down_20251111153133(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20251111153133_AddUrlToInHouseApps_test.go b/server/datastore/mysql/migrations/tables/20251111153133_AddUrlToInHouseApps_test.go new file mode 100644 index 0000000000..c1633e4385 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20251111153133_AddUrlToInHouseApps_test.go @@ -0,0 +1,13 @@ +package tables + +import "testing" + +func TestUp_20251111153133(t *testing.T) { + db := applyUpToPrev(t) + + // Just a new column, so no logic to test here. + // Leaving it in because it's nice to validate that the migration applies successfully. + + // Apply current migration. + applyNext(t, db) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 9de5793ff8..75d248418f 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -1178,6 +1178,7 @@ CREATE TABLE `in_house_apps` ( `platform` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, `bundle_identifier` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', `self_service` tinyint(1) NOT NULL DEFAULT '0', + `url` varchar(4095) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', PRIMARY KEY (`id`), UNIQUE KEY `global_or_team_id` (`global_or_team_id`,`filename`,`platform`), KEY `fk_in_house_apps_title` (`title_id`), @@ -1680,9 +1681,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=443 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB AUTO_INCREMENT=444 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'); +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'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 471ae31832..7a6c25ee99 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -267,7 +267,8 @@ func (ds *Datastore) MatchOrCreateSoftwareInstaller(ctx context.Context, payload // 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. - if !(found.Title == payload.Title && found.Source == payload.Source) { + // 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 team.", @@ -2745,6 +2746,10 @@ func (ds *Datastore) UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Contex return nil } +// GetSoftwareInstallers returns all software installers, including in-house +// apps, for the specified team. The reason why installers and in-house apps +// are returned together is that this is used in the gitops flow, where both +// types of installers are specified in the same "packages" key in the yaml. func (ds *Datastore) GetSoftwareInstallers(ctx context.Context, teamID uint) ([]fleet.SoftwarePackageResponse, error) { const loadInsertedSoftwareInstallers = ` SELECT @@ -2755,13 +2760,34 @@ SELECT si.fleet_maintained_app_id, COALESCE(icons.filename, '') AS icon_filename, COALESCE(icons.storage_id, '') AS icon_hash_sha256 -FROM software_installers si -LEFT JOIN software_title_icons icons ON icons.software_title_id = si.title_id AND icons.team_id = si.global_or_team_id -WHERE global_or_team_id = ? +FROM + software_installers si + LEFT JOIN software_title_icons icons ON + icons.software_title_id = si.title_id AND icons.team_id = si.global_or_team_id +WHERE + global_or_team_id = ? + +UNION ALL + +SELECT + iha.team_id, + iha.title_id, + iha.url, + iha.storage_id as hash_sha256, + NULL as fleet_maintained_app_id, + COALESCE(icons.filename, '') AS icon_filename, + COALESCE(icons.storage_id, '') AS icon_hash_sha256 +FROM + in_house_apps iha + LEFT JOIN software_title_icons icons ON + icons.software_title_id = iha.title_id AND icons.team_id = iha.global_or_team_id +WHERE + iha.global_or_team_id = ? ` var softwarePackages []fleet.SoftwarePackageResponse // Using ds.writer(ctx) on purpose because this method is to be called after applying software. - if err := sqlx.SelectContext(ctx, ds.writer(ctx), &softwarePackages, loadInsertedSoftwareInstallers, teamID); err != nil { + if err := sqlx.SelectContext(ctx, ds.writer(ctx), &softwarePackages, + loadInsertedSoftwareInstallers, teamID, teamID); err != nil { return nil, ctxerr.Wrap(ctx, err, "get software installers") } return softwarePackages, nil @@ -2964,7 +2990,11 @@ WHERE return res, nil } -func (ds *Datastore) GetTeamsWithInstallerByHash(ctx context.Context, sha256, url string) (map[uint]*fleet.ExistingSoftwareInstaller, error) { +// GetTeamsWithInstallerByHash retrieves all software installers and in-house apps +// matching the given sha256 hash (storage_id) and optional URL, grouped by team ID. +// Software installers can only have at most 1 installer per team for the given hash, +// while in-house apps can have multiple (1 for ios and 1 for ipados). +func (ds *Datastore) GetTeamsWithInstallerByHash(ctx context.Context, sha256, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { stmt := ` SELECT si.id AS installer_id, @@ -2981,7 +3011,27 @@ FROM software_installers si JOIN software_titles st ON si.title_id = st.id WHERE - si.storage_id = ?%s` + si.storage_id = ? %s + +UNION ALL + +SELECT + iha.id AS installer_id, + iha.team_id AS team_id, + iha.filename AS filename, + 'ipa' AS extension, + iha.version AS version, + iha.platform AS platform, + st.source AS source, + st.bundle_identifier AS bundle_identifier, + st.name AS title, + '' AS package_ids +FROM + in_house_apps iha + JOIN software_titles st ON iha.title_id = st.id +WHERE + iha.storage_id = ? %s +` var urlFilter string args := []any{sha256} @@ -2989,28 +3039,29 @@ WHERE urlFilter = " AND url = ?" args = append(args, url) } - stmt = fmt.Sprintf(stmt, urlFilter) + stmt = fmt.Sprintf(stmt, urlFilter, urlFilter) + args = append(args, args...) var installers []*fleet.ExistingSoftwareInstaller if err := sqlx.SelectContext(ctx, ds.writer(ctx), &installers, stmt, args...); err != nil { return nil, ctxerr.Wrap(ctx, err, "get software installer by hash") } - set := make(map[uint]*fleet.ExistingSoftwareInstaller, len(installers)) + byTeam := make(map[uint][]*fleet.ExistingSoftwareInstaller, len(installers)) for _, installer := range installers { // team ID 0 is No team in this context var tmID uint if installer.TeamID != nil { tmID = *installer.TeamID } - if _, ok := set[tmID]; ok { + if _, ok := byTeam[tmID]; ok && installer.Extension != "ipa" { return nil, ctxerr.New(ctx, fmt.Sprintf("cannot have multiple installers with the same hash %q on one team", sha256)) } if installer.PackageIDList != "" { installer.PackageIDs = strings.Split(installer.PackageIDList, ",") } - set[tmID] = installer + byTeam[tmID] = append(byTeam[tmID], installer) } - return set, nil + return byTeam, nil } diff --git a/server/datastore/mysql/software_installers_test.go b/server/datastore/mysql/software_installers_test.go index 9d01ad9829..c93f89c8a5 100644 --- a/server/datastore/mysql/software_installers_test.go +++ b/server/datastore/mysql/software_installers_test.go @@ -2983,6 +2983,20 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) { }) require.NoError(t, err) + // add an in-house app to the team + _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + TeamID: &team1.ID, + UserID: user.ID, + Title: "inhouse", + Filename: "inhouse.ipa", + BundleIdentifier: "com.inhouse", + StorageID: "inhouse", + Extension: "ipa", + Version: "1.2.3", + ValidatedLabels: &fleet.LabelIdentsWithScope{}, + }) + require.NoError(t, err) + // get installer IDs from added installers var installer1NoTeam, installer1Team1, installer2NoTeam uint ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { @@ -3010,14 +3024,17 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, installers, 2) - require.Equal(t, installer1NoTeam, installers[0].InstallerID) - require.Nil(t, installers[0].TeamID) + require.Len(t, installers[0], 1) + require.Equal(t, installer1NoTeam, installers[0][0].InstallerID) + require.Nil(t, installers[0][0].TeamID) - require.Equal(t, installer1Team1, installers[1].InstallerID) - require.NotNil(t, installers[1].TeamID) - require.Equal(t, team1.ID, *installers[1].TeamID) + require.Len(t, installers[1], 1) + require.Equal(t, installer1Team1, installers[1][0].InstallerID) + require.NotNil(t, installers[1][0].TeamID) + require.Equal(t, team1.ID, *installers[1][0].TeamID) - for _, i := range installers { + for _, is := range installers { + i := is[0] require.Equal(t, "installer1", i.Title) require.Equal(t, "pkg", i.Extension) require.Equal(t, "1.0", i.Version) @@ -3027,7 +3044,26 @@ func testGetTeamsWithInstallerByHash(t *testing.T, ds *Datastore) { installers, err = ds.GetTeamsWithInstallerByHash(ctx, hash2, "https://example.com/2") require.NoError(t, err) require.Len(t, installers, 1) - require.Equal(t, installers[0].InstallerID, installer2NoTeam) + require.Len(t, installers[0], 1) + require.Equal(t, installers[0][0].InstallerID, installer2NoTeam) + + // in-house hash with invalid url + installers, err = ds.GetTeamsWithInstallerByHash(ctx, "inhouse", "https://no-such-match") + require.NoError(t, err) + require.Len(t, installers, 0) + + // in-house hash without url match + installers, err = ds.GetTeamsWithInstallerByHash(ctx, "inhouse", "") + require.NoError(t, err) + require.Len(t, installers, 1) + require.Len(t, installers[team1.ID], 2) // ios and ipados + require.Equal(t, "inhouse.ipa", installers[team1.ID][0].Filename) + require.Equal(t, "inhouse.ipa", installers[team1.ID][1].Filename) + var foundPlatforms []string + for _, inst := range installers[team1.ID] { + foundPlatforms = append(foundPlatforms, inst.Platform) + } + require.ElementsMatch(t, []string{"ios", "ipados"}, foundPlatforms) } func testEditDeleteSoftwareInstallersActivateNextActivity(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/testing_utils.go b/server/datastore/mysql/testing_utils.go index 3cd06ef7c4..9b52642899 100644 --- a/server/datastore/mysql/testing_utils.go +++ b/server/datastore/mysql/testing_utils.go @@ -445,6 +445,7 @@ func TruncateTables(t testing.TB, ds *Datastore, tables ...string) { "mdm_operation_types": true, "migration_status_tables": true, "osquery_options": true, + "software_categories": true, } testing_utils.TruncateTables(t, ds.writer(context.Background()), ds.logger, nonEmptyTables, tables...) } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 9dc0174bfc..192b93af4e 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2100,6 +2100,8 @@ type Datastore interface { // BatchSetSoftwareInstallers sets the software installers for the given team or no team. BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, installers []*UploadSoftwareInstallerPayload) error + // BatchSetInHouseAppsInstallers sets the in-house apps installers for the given team or no team. + BatchSetInHouseAppsInstallers(ctx context.Context, tmID *uint, installers []*UploadSoftwareInstallerPayload) error GetSoftwareInstallers(ctx context.Context, tmID uint) ([]SoftwarePackageResponse, error) // HasSelfServiceSoftwareInstallers returns true if self-service software installers are available for the team or globally. @@ -2158,7 +2160,7 @@ type Datastore interface { // GetTeamsWithInstallerByHash gets a map of teamIDs (0 for No team) to software installers // metadata by the installer's hash. - GetTeamsWithInstallerByHash(ctx context.Context, sha256, url string) (map[uint]*ExistingSoftwareInstaller, error) + GetTeamsWithInstallerByHash(ctx context.Context, sha256, url string) (map[uint][]*ExistingSoftwareInstaller, error) // TeamIDsWithSetupExperienceIdPEnabled returns the list of team IDs that // have the setup experience IdP (End user authentication) enabled. It uses diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index a237c8d9ab..c291a8d333 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -520,17 +520,17 @@ type UploadSoftwareInstallerPayload struct { } type ExistingSoftwareInstaller struct { - InstallerID uint `db:"installer_id"` - TeamID *uint `db:"team_id"` - Filename string `db:"filename"` - Extension string `db:"extension"` - Version string `db:"version"` - Platform string `db:"platform"` - Source string `db:"source"` - BundleIdentifier *string `db:"bundle_identifier"` - Title string `db:"title"` - PackageIDList string `db:"package_ids"` - PackageIDs []string `` + InstallerID uint `db:"installer_id"` + TeamID *uint `db:"team_id"` + Filename string `db:"filename"` + Extension string `db:"extension"` + Version string `db:"version"` + Platform string `db:"platform"` + Source string `db:"source"` + BundleIdentifier *string `db:"bundle_identifier"` + Title string `db:"title"` + PackageIDList string `db:"package_ids"` + PackageIDs []string } type UpdateSoftwareInstallerPayload struct { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 2bf9294771..4e27174d89 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1329,6 +1329,8 @@ type CleanupUnusedSoftwareTitleIconsFunc func(ctx context.Context, softwareTitle type BatchSetSoftwareInstallersFunc func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error +type BatchSetInHouseAppsInstallersFunc func(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error + type GetSoftwareInstallersFunc func(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) type HasSelfServiceSoftwareInstallersFunc func(ctx context.Context, platform string, teamID *uint) (bool, error) @@ -1381,7 +1383,7 @@ type SetHostAwaitingConfigurationFunc func(ctx context.Context, hostUUID string, type GetHostAwaitingConfigurationFunc func(ctx context.Context, hostUUID string) (bool, error) -type GetTeamsWithInstallerByHashFunc func(ctx context.Context, sha256 string, url string) (map[uint]*fleet.ExistingSoftwareInstaller, error) +type GetTeamsWithInstallerByHashFunc func(ctx context.Context, sha256 string, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) type TeamIDsWithSetupExperienceIdPEnabledFunc func(ctx context.Context) ([]uint, error) @@ -3561,6 +3563,9 @@ type DataStore struct { BatchSetSoftwareInstallersFunc BatchSetSoftwareInstallersFunc BatchSetSoftwareInstallersFuncInvoked bool + BatchSetInHouseAppsInstallersFunc BatchSetInHouseAppsInstallersFunc + BatchSetInHouseAppsInstallersFuncInvoked bool + GetSoftwareInstallersFunc GetSoftwareInstallersFunc GetSoftwareInstallersFuncInvoked bool @@ -8543,6 +8548,13 @@ func (s *DataStore) BatchSetSoftwareInstallers(ctx context.Context, tmID *uint, return s.BatchSetSoftwareInstallersFunc(ctx, tmID, installers) } +func (s *DataStore) BatchSetInHouseAppsInstallers(ctx context.Context, tmID *uint, installers []*fleet.UploadSoftwareInstallerPayload) error { + s.mu.Lock() + s.BatchSetInHouseAppsInstallersFuncInvoked = true + s.mu.Unlock() + return s.BatchSetInHouseAppsInstallersFunc(ctx, tmID, installers) +} + func (s *DataStore) GetSoftwareInstallers(ctx context.Context, tmID uint) ([]fleet.SoftwarePackageResponse, error) { s.mu.Lock() s.GetSoftwareInstallersFuncInvoked = true @@ -8725,7 +8737,7 @@ func (s *DataStore) GetHostAwaitingConfiguration(ctx context.Context, hostUUID s return s.GetHostAwaitingConfigurationFunc(ctx, hostUUID) } -func (s *DataStore) GetTeamsWithInstallerByHash(ctx context.Context, sha256 string, url string) (map[uint]*fleet.ExistingSoftwareInstaller, error) { +func (s *DataStore) GetTeamsWithInstallerByHash(ctx context.Context, sha256 string, url string) (map[uint][]*fleet.ExistingSoftwareInstaller, error) { s.mu.Lock() s.GetTeamsWithInstallerByHashFuncInvoked = true s.mu.Unlock() diff --git a/server/service/testdata/software-installers/ipa_test2.ipa b/server/service/testdata/software-installers/ipa_test2.ipa new file mode 100644 index 0000000000000000000000000000000000000000..177ef455f0950a62594f82839b51bb6810731e62 GIT binary patch literal 20650 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 Q=H_VJ6sdjMe3ygcznF7M#Q*>R literal 0 HcmV?d00001