mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
Uninstall migration cron job (#22036)
This commit is contained in:
parent
f71d399b13
commit
3eccbb1bd0
9 changed files with 287 additions and 2 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue