Skip to content

Commit

Permalink
Uninstall migration cron job (#22036)
Browse files Browse the repository at this point in the history
  • Loading branch information
getvictor authored Sep 13, 2024
1 parent f71d399 commit 3eccbb1
Show file tree
Hide file tree
Showing 9 changed files with 287 additions and 2 deletions.
26 changes: 26 additions & 0 deletions cmd/fleet/cron.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
10 changes: 10 additions & 0 deletions cmd/fleet/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
66 changes: 66 additions & 0 deletions ee/server/service/software_installers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
41 changes: 41 additions & 0 deletions server/datastore/mysql/software_installers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions server/fleet/cron_schedules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions server/fleet/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions server/mock/datastore_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2615,6 +2619,12 @@ type DataStore struct {
GetSoftwareInstallerMetadataByTeamAndTitleIDFunc GetSoftwareInstallerMetadataByTeamAndTitleIDFunc
GetSoftwareInstallerMetadataByTeamAndTitleIDFuncInvoked bool

GetSoftwareInstallersWithoutPackageIDsFunc GetSoftwareInstallersWithoutPackageIDsFunc
GetSoftwareInstallersWithoutPackageIDsFuncInvoked bool

UpdateSoftwareInstallerWithoutPackageIDsFunc UpdateSoftwareInstallerWithoutPackageIDsFunc
UpdateSoftwareInstallerWithoutPackageIDsFuncInvoked bool

GetVPPAppByTeamAndTitleIDFunc GetVPPAppByTeamAndTitleIDFunc
GetVPPAppByTeamAndTitleIDFuncInvoked bool

Expand Down Expand Up @@ -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
Expand Down
103 changes: 101 additions & 2 deletions server/service/integration_enterprise_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand All @@ -98,6 +109,7 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() {
}
},
},
SoftwareInstallStore: softwareInstallStore,
}
if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" {
config.Logger = kitlog.NewNopLogger()
Expand Down Expand Up @@ -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() {
Expand Down
Loading

0 comments on commit 3eccbb1

Please sign in to comment.