From 3eccbb1bd09a13af7fbed79f345d3865505b0e85 Mon Sep 17 00:00:00 2001 From: Victor Lyuboslavsky Date: Thu, 12 Sep 2024 20:07:56 -0500 Subject: [PATCH] Uninstall migration cron job (#22036) --- cmd/fleet/cron.go | 26 +++++ cmd/fleet/serve.go | 10 ++ ee/server/service/software_installers.go | 66 +++++++++++ server/datastore/mysql/software_installers.go | 41 +++++++ server/fleet/cron_schedules.go | 1 + server/fleet/datastore.go | 6 + server/mock/datastore_mock.go | 24 ++++ server/service/integration_enterprise_test.go | 103 +++++++++++++++++- server/service/schedule/schedule.go | 12 ++ 9 files changed, 287 insertions(+), 2 deletions(-) diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index 96e7f7998f..83b50d4eef 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -11,6 +11,7 @@ import ( "strings" "time" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" eewebhooks "github.com/fleetdm/fleet/v4/ee/server/webhooks" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/config" @@ -1394,3 +1395,28 @@ func newIPhoneIPadRefetcher( return s, nil } + +// cronUninstallSoftwareMigration will update uninstall scripts for software. +// Once all customers are using on Fleet 4.57 or later, this job can be removed. +func cronUninstallSoftwareMigration( + ctx context.Context, + instanceID string, + ds fleet.Datastore, + softwareInstallStore fleet.SoftwareInstallerStore, + logger kitlog.Logger, +) (*schedule.Schedule, error) { + const ( + name = string(fleet.CronUninstallSoftwareMigration) + defaultInterval = 24 * time.Hour + ) + logger = kitlog.With(logger, "cron", name, "component", name) + s := schedule.New( + ctx, name, instanceID, defaultInterval, ds, ds, + schedule.WithLogger(logger), + schedule.WithRunOnce(true), + schedule.WithJob(name, func(ctx context.Context) error { + return eeservice.UninstallSoftwareMigration(ctx, ds, softwareInstallStore, logger) + }), + ) + return s, nil +} diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 19dfd798aa..eabe158c70 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -829,6 +829,16 @@ the way that the Fleet server works. } }() + if softwareInstallStore != nil { + if err := cronSchedules.StartCronSchedule( + func() (fleet.CronSchedule, error) { + return cronUninstallSoftwareMigration(ctx, instanceID, ds, softwareInstallStore, logger) + }, + ); err != nil { + initFatal(err, fmt.Sprintf("failed to register %s", fleet.CronUninstallSoftwareMigration)) + } + } + if config.Server.FrequentCleanupsEnabled { if err := cronSchedules.StartCronSchedule( func() (fleet.CronSchedule, error) { diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 5c090c6353..5826f488a1 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -24,6 +24,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/ptr" + kitlog "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/google/uuid" "golang.org/x/sync/errgroup" @@ -1165,3 +1166,68 @@ func packageExtensionToPlatform(ext string) string { return requiredPlatform } + +func UninstallSoftwareMigration( + ctx context.Context, + ds fleet.Datastore, + softwareInstallStore fleet.SoftwareInstallerStore, + logger kitlog.Logger, +) error { + // Find software installers without package_id + idMap, err := ds.GetSoftwareInstallersWithoutPackageIDs(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting software installers without package_id") + } + if len(idMap) == 0 { + return nil + } + + // Download each package and parse it + for id, storageID := range idMap { + // check if the installer exists in the store + exists, err := softwareInstallStore.Exists(ctx, storageID) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking if installer exists") + } + if !exists { + level.Warn(logger).Log("msg", "software installer not found in store", "software_installer_id", id, "storage_id", storageID) + continue + } + + // get the installer from the store + installer, _, err := softwareInstallStore.Get(ctx, storageID) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting installer from store") + } + + meta, err := file.ExtractInstallerMetadata(installer) + if err != nil { + level.Warn(logger).Log("msg", "extracting metadata from installer", "software_installer_id", id, "storage_id", storageID, "err", + err) + continue + } + if len(meta.PackageIDs) == 0 { + level.Warn(logger).Log("msg", "no package_id found in metadata", "software_installer_id", id, "storage_id", storageID) + continue + } + if meta.Extension == "" { + level.Warn(logger).Log("msg", "no extension found in metadata", "software_installer_id", id, "storage_id", storageID) + continue + } + payload := fleet.UploadSoftwareInstallerPayload{ + PackageIDs: meta.PackageIDs, + Extension: meta.Extension, + } + payload.UninstallScript = file.GetUninstallScript(payload.Extension) + + // Update $PACKAGE_ID in uninstall script + preProcessUninstallScript(&payload) + + // Update the package_id in the software installer and the uninstall script + if err := ds.UpdateSoftwareInstallerWithoutPackageIDs(ctx, id, payload); err != nil { + return ctxerr.Wrap(ctx, err, "updating package_id in software installer") + } + } + + return nil +} diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 774134935d..ee314da7d2 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -927,3 +927,44 @@ func (ds *Datastore) GetSoftwareTitleNameFromExecutionID(ctx context.Context, ex } return name, nil } + +func (ds *Datastore) GetSoftwareInstallersWithoutPackageIDs(ctx context.Context) (map[uint]string, error) { + query := ` + SELECT id, storage_id FROM software_installers WHERE package_ids = '' + ` + type result struct { + ID uint `db:"id"` + StorageID string `db:"storage_id"` + } + + var results []result + if err := sqlx.SelectContext(ctx, ds.reader(ctx), &results, query); err != nil { + return nil, ctxerr.Wrap(ctx, err, "get software installers without package ID") + } + if len(results) == 0 { + return nil, nil + } + idMap := make(map[uint]string, len(results)) + for _, r := range results { + idMap[r.ID] = r.StorageID + } + return idMap, nil +} + +func (ds *Datastore) UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint, + payload fleet.UploadSoftwareInstallerPayload) error { + uninstallScriptID, err := ds.getOrGenerateScriptContentsID(ctx, payload.UninstallScript) + if err != nil { + return ctxerr.Wrap(ctx, err, "get or generate uninstall script contents ID") + } + query := ` + UPDATE software_installers + SET package_ids = ?, uninstall_script_content_id = ? + WHERE id = ? + ` + _, err = ds.writer(ctx).ExecContext(ctx, query, strings.Join(payload.PackageIDs, ","), uninstallScriptID, id) + if err != nil { + return ctxerr.Wrap(ctx, err, "update software installer without package ID") + } + return nil +} diff --git a/server/fleet/cron_schedules.go b/server/fleet/cron_schedules.go index 250a8dc3b5..937fb85a51 100644 --- a/server/fleet/cron_schedules.go +++ b/server/fleet/cron_schedules.go @@ -24,6 +24,7 @@ const ( CronAppleMDMIPhoneIPadRefetcher CronScheduleName = "apple_mdm_iphone_ipad_refetcher" CronAppleMDMAPNsPusher CronScheduleName = "apple_mdm_apns_pusher" CronCalendar CronScheduleName = "calendar" + CronUninstallSoftwareMigration CronScheduleName = "uninstall_software_migration" ) type CronSchedulesService interface { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 80aede54b6..8184ca5015 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1662,6 +1662,12 @@ type Datastore interface { // (if set) post-install scripts, otherwise those fields are left empty. GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) + // GetSoftwareInstallersWithoutPackageIDs returns a map of software installers to storage ids that do not have a package ID. + GetSoftwareInstallersWithoutPackageIDs(ctx context.Context) (map[uint]string, error) + + // UpdateSoftwareInstallerWithoutPackageIDs updates the software installer corresponding to the id. Used to add uninstall scripts. + UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint, payload UploadSoftwareInstallerPayload) error + GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*VPPApp, error) // GetVPPAppMetadataByTeamAndTitleID returns the VPP app corresponding to the // specified team and title ids. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 8634e6662a..323144afd7 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1044,6 +1044,10 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) +type GetSoftwareInstallersWithoutPackageIDsFunc func(ctx context.Context) (map[uint]string, error) + +type UpdateSoftwareInstallerWithoutPackageIDsFunc func(ctx context.Context, id uint, payload fleet.UploadSoftwareInstallerPayload) error + type GetVPPAppByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) type GetVPPAppMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPAppStoreApp, error) @@ -2615,6 +2619,12 @@ type DataStore struct { GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool + GetSoftwareInstallersWithoutPackageIDsFunc GetSoftwareInstallersWithoutPackageIDsFunc + GetSoftwareInstallersWithoutPackageIDsFuncInvoked bool + + UpdateSoftwareInstallerWithoutPackageIDsFunc UpdateSoftwareInstallerWithoutPackageIDsFunc + UpdateSoftwareInstallerWithoutPackageIDsFuncInvoked bool + GetVPPAppByTeamAndTitleIDFunc GetVPPAppByTeamAndTitleIDFunc GetVPPAppByTeamAndTitleIDFuncInvoked bool @@ -6253,6 +6263,20 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } +func (s *DataStore) GetSoftwareInstallersWithoutPackageIDs(ctx context.Context) (map[uint]string, error) { + s.mu.Lock() + s.GetSoftwareInstallersWithoutPackageIDsFuncInvoked = true + s.mu.Unlock() + return s.GetSoftwareInstallersWithoutPackageIDsFunc(ctx) +} + +func (s *DataStore) UpdateSoftwareInstallerWithoutPackageIDs(ctx context.Context, id uint, payload fleet.UploadSoftwareInstallerPayload) error { + s.mu.Lock() + s.UpdateSoftwareInstallerWithoutPackageIDsFuncInvoked = true + s.mu.Unlock() + return s.UpdateSoftwareInstallerWithoutPackageIDsFunc(ctx, id, payload) +} + func (s *DataStore) GetVPPAppByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint) (*fleet.VPPApp, error) { s.mu.Lock() s.GetVPPAppByTeamAndTitleIDFuncInvoked = true diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 40f7fb970c..d871544aeb 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -24,11 +24,14 @@ import ( "time" "github.com/fleetdm/fleet/v4/ee/server/calendar" + eeservice "github.com/fleetdm/fleet/v4/ee/server/service" + "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/pkg/scripts" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/cron" + "github.com/fleetdm/fleet/v4/server/datastore/filesystem" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/datastore/redis/redistest" "github.com/fleetdm/fleet/v4/server/fleet" @@ -60,8 +63,9 @@ func TestIntegrationsEnterprise(t *testing.T) { type integrationEnterpriseTestSuite struct { withServer suite.Suite - redisPool fleet.RedisPool - calendarSchedule *schedule.Schedule + redisPool fleet.RedisPool + calendarSchedule *schedule.Schedule + softwareInstallStore fleet.SoftwareInstallerStore lq *live_query_mock.MockLiveQuery } @@ -72,6 +76,13 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { s.redisPool = redistest.SetupRedis(s.T(), "integration_enterprise", false, false, false) s.lq = live_query_mock.New(s.T()) var calendarSchedule *schedule.Schedule + + // Create a software install store + dir := s.T().TempDir() + softwareInstallStore, err := filesystem.NewSoftwareInstallerStore(dir) + require.NoError(s.T(), err) + s.softwareInstallStore = softwareInstallStore + config := TestServerOpts{ License: &fleet.LicenseInfo{ Tier: fleet.TierPremium, @@ -98,6 +109,7 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { } }, }, + SoftwareInstallStore: softwareInstallStore, } if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { config.Logger = kitlog.NewNopLogger() @@ -10540,6 +10552,93 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD // download the installer, not found anymore s.Do("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d/package?alt=media", titleID), nil, http.StatusNotFound, "team_id", fmt.Sprintf("%d", 0)) }) + + t.Run("uninstall migration for software installer", func(t *testing.T) { + var createTeamResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{ + Name: t.Name(), + }, http.StatusOK, &createTeamResp) + require.NotZero(t, createTeamResp.Team.ID) + + payload := &fleet.UploadSoftwareInstallerPayload{ + TeamID: &createTeamResp.Team.ID, + InstallScript: "another install script", + UninstallScript: "exit 1", + Filename: "ruby.deb", + // additional fields below are pre-populated so we can re-use the payload later for the test assertions + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + } + s.uploadSoftwareInstaller(payload, http.StatusOK, "") + + logger := kitlog.NewLogfmtLogger(os.Stderr) + + // Run the migration when nothing is to be done + err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger) + require.NoError(t, err) + + // check the software installer + installerID, titleID := checkSoftwareInstaller(t, payload) + + var origPackageIDs string + // Update DB by clearing package id + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + if err := sqlx.GetContext(context.Background(), q, &origPackageIDs, `SELECT package_ids FROM software_installers WHERE id = ?`, + installerID); err != nil { + return err + } + require.NotEmpty(t, origPackageIDs) + if _, err = q.ExecContext(context.Background(), `UPDATE software_installers SET package_ids = '' WHERE id = ?`, + installerID); err != nil { + return err + } + return nil + }) + + // Check title to make it works without package id + respTitle := getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id", + fmt.Sprintf("%d", createTeamResp.Team.ID)) + require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage) + assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript) + assert.Equal(t, "exit 1", respTitle.SoftwareTitle.SoftwarePackage.UninstallScript) + + // Run the migration + err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger) + require.NoError(t, err) + + // Check package ID + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + var packageIDs string + if err := sqlx.GetContext(context.Background(), q, &packageIDs, `SELECT package_ids FROM software_installers WHERE id = ?`, + installerID); err != nil { + return err + } + assert.Equal(t, origPackageIDs, packageIDs) + return nil + }) + + // Check uninstall script + uninstallScript := file.GetUninstallScript("deb") + uninstallScript = strings.ReplaceAll(uninstallScript, "$PACKAGE_ID", "\"ruby\"") + respTitle = getSoftwareTitleResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &respTitle, "team_id", + fmt.Sprintf("%d", createTeamResp.Team.ID)) + require.NotNil(t, respTitle.SoftwareTitle.SoftwarePackage) + assert.Equal(t, "another install script", respTitle.SoftwareTitle.SoftwarePackage.InstallScript) + assert.Equal(t, uninstallScript, respTitle.SoftwareTitle.SoftwarePackage.UninstallScript) + + // Running the migration again causes no issues. + err = eeservice.UninstallSoftwareMigration(context.Background(), s.ds, s.softwareInstallStore, logger) + require.NoError(t, err) + + // delete the installer + s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/titles/%d/available_for_install", titleID), nil, http.StatusNoContent, + "team_id", fmt.Sprintf("%d", *payload.TeamID)) + }) } func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { diff --git a/server/service/schedule/schedule.go b/server/service/schedule/schedule.go index 7ca865416a..be6377c91b 100644 --- a/server/service/schedule/schedule.go +++ b/server/service/schedule/schedule.go @@ -47,6 +47,8 @@ type Schedule struct { jobs []Job statsStore CronStatsStore + + runOnce bool } // JobFn is the signature of a Job. @@ -120,6 +122,13 @@ func WithJob(id string, fn JobFn) Option { } } +// WithRunOnce sets the Schedule to run only once. +func WithRunOnce(once bool) Option { + return func(s *Schedule) { + s.runOnce = once + } +} + // New creates and returns a Schedule. // Jobs are added with the WithJob Option. // @@ -172,6 +181,9 @@ func (s *Schedule) Start() { startedAt := prevScheduledRun.CreatedAt if startedAt.IsZero() { startedAt = time.Now() + } else if s.runOnce && prevScheduledRun.Status == fleet.CronStatsStatusCompleted { + // If job is set to run once, and it already ran, then nothing to do + return } s.setIntervalStartedAt(startedAt)