Track software deletions in GitOps (#46764)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #43729 # Details Adds output to GitOps runs indicating which custom/FMA software packages would be deleted. This involves adding a `deleted_packages` key to the `/software/batch/:request_uuid` ("Get status of software batch-apply request") API, which will be documented separately. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [X] Added/updated automated tests - [X] QA'd all new/changed functionality manually - [X] verified that a GitOps dry run produces one "would've deleted" line per custom package / fma that would be deleted - [X] verified that a GitOps real run produces one "deleted" line per custom package / fma that was deleted - [X] verified that adding software is unaffected <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * GitOps batch software operations now report packages pending deletion: dry-runs show "would've deleted" warnings and real runs show deletions; apply flows surface per-package deletion messages. * Empty payload dry-run now still reports pending deletions when applicable. * **Tests** * Added integration and datastore tests validating deletion-warning output, pending-deletion detection, and related result handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -3360,6 +3360,49 @@ WHERE
|
||||
return softwarePackages, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetSoftwareInstallersPendingDeletion(ctx context.Context, tmID *uint, incoming []fleet.SoftwareTitleIdentifier) ([]fleet.DeletedSoftwarePackage, error) {
|
||||
var globalOrTeamID uint
|
||||
if tmID != nil {
|
||||
globalOrTeamID = *tmID
|
||||
}
|
||||
|
||||
// An installer survives a batch set iff its title matches an incoming
|
||||
// (unique_identifier, source) key with extension_for = '' — the same
|
||||
// matching BatchSetSoftwareInstallers uses to upsert titles before
|
||||
// deleting installers with title_id NOT IN the upserted set. DISTINCT
|
||||
// collapses multiple installer rows (cached FMA versions) of one title.
|
||||
stmt := `
|
||||
SELECT DISTINCT
|
||||
si.team_id,
|
||||
st.id AS title_id,
|
||||
COALESCE(NULLIF(stdn.display_name, ''), st.name) AS display_name
|
||||
FROM
|
||||
software_installers si
|
||||
JOIN software_titles st ON st.id = si.title_id
|
||||
LEFT JOIN software_title_display_names stdn ON
|
||||
stdn.software_title_id = st.id AND stdn.team_id = si.global_or_team_id
|
||||
WHERE
|
||||
si.global_or_team_id = ?`
|
||||
|
||||
args := []any{globalOrTeamID}
|
||||
if len(incoming) > 0 {
|
||||
stmt += fmt.Sprintf(` AND NOT (st.extension_for = '' AND (st.unique_identifier, st.source) IN (%s))`,
|
||||
strings.TrimSuffix(strings.Repeat("(?,?),", len(incoming)), ","))
|
||||
for _, ti := range incoming {
|
||||
args = append(args, ti.UniqueIdentifier, ti.Source)
|
||||
}
|
||||
}
|
||||
stmt += ` ORDER BY display_name, title_id`
|
||||
|
||||
var deleted []fleet.DeletedSoftwarePackage
|
||||
// Using ds.writer(ctx) on purpose: this runs during batch-set processing and must
|
||||
// see the same state the deletion will operate on (no replica lag).
|
||||
if err := sqlx.SelectContext(ctx, ds.writer(ctx), &deleted, stmt, args...); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get software installers pending deletion")
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) IsSoftwareInstallerLabelScoped(ctx context.Context, installerID, hostID uint) (bool, error) {
|
||||
return ds.isSoftwareLabelScoped(ctx, installerID, hostID, softwareTypeInstaller)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestSoftwareInstallers(t *testing.T) {
|
||||
{"CleanupUnusedSoftwareInstallers", testCleanupUnusedSoftwareInstallers},
|
||||
{"BatchSetSoftwareInstallers", testBatchSetSoftwareInstallers},
|
||||
{"BatchSetSoftwareInstallersWithUpgradeCodes", testBatchSetSoftwareInstallersWithUpgradeCodes},
|
||||
{"GetSoftwareInstallersPendingDeletion", testGetSoftwareInstallersPendingDeletion},
|
||||
{"GetSoftwareInstallerMetadataByTeamAndTitleID", testGetSoftwareInstallerMetadataByTeamAndTitleID},
|
||||
{"HasSelfServiceSoftwareInstallers", testHasSelfServiceSoftwareInstallers},
|
||||
{"DeleteSoftwareInstallers", testDeleteSoftwareInstallers},
|
||||
@@ -2017,6 +2018,164 @@ func testBatchSetSoftwareInstallersSetupExperienceSideEffects(t *testing.T, ds *
|
||||
}
|
||||
}
|
||||
|
||||
func testGetSoftwareInstallersPendingDeletion(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
|
||||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: t.Name()})
|
||||
require.NoError(t, err)
|
||||
user1 := test.NewUser(t, ds, "Alice", "alice@example.com", true)
|
||||
|
||||
newTFR := func(content string) *fleet.TempFileReader {
|
||||
tfr, err := fleet.NewTempFileReader(bytes.NewReader([]byte(content)), t.TempDir)
|
||||
require.NoError(t, err)
|
||||
return tfr
|
||||
}
|
||||
|
||||
maintainedApp, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
|
||||
Name: "Maintained1",
|
||||
Slug: "maintained1",
|
||||
Platform: "darwin",
|
||||
UniqueIdentifier: "fleet.maintained1",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Three installers: a macOS package with a bundle identifier and a display
|
||||
// name override, a Windows package matched by name (no bundle identifier),
|
||||
// and an FMA-backed package.
|
||||
err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{
|
||||
{
|
||||
InstallScript: "install",
|
||||
InstallerFile: newTFR("installer0"),
|
||||
StorageID: "installer0",
|
||||
Filename: "installer0",
|
||||
Title: "ins0",
|
||||
Source: "apps",
|
||||
Version: "1",
|
||||
UserID: user1.ID,
|
||||
Platform: "darwin",
|
||||
URL: "https://example.com/ins0",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
BundleIdentifier: "com.example.ins0",
|
||||
DisplayName: "Cool App",
|
||||
},
|
||||
{
|
||||
InstallScript: "install",
|
||||
UninstallScript: "uninstall",
|
||||
InstallerFile: newTFR("installer1"),
|
||||
StorageID: "installer1",
|
||||
Filename: "installer1",
|
||||
Title: "ins1",
|
||||
Source: "programs",
|
||||
Version: "2",
|
||||
UserID: user1.ID,
|
||||
Platform: "windows",
|
||||
URL: "https://example.com/ins1",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
},
|
||||
{
|
||||
InstallScript: "install",
|
||||
InstallerFile: newTFR("installer2"),
|
||||
StorageID: "installer2",
|
||||
Filename: "installer2",
|
||||
Title: "Maintained1",
|
||||
Source: "apps",
|
||||
Version: "3",
|
||||
UserID: user1.ID,
|
||||
Platform: "darwin",
|
||||
URL: "https://example.com/maintained1",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
BundleIdentifier: "fleet.maintained1",
|
||||
FleetMaintainedAppID: new(maintainedApp.ID),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
displayNames := func(pkgs []fleet.DeletedSoftwarePackage) []string {
|
||||
names := make([]string, 0, len(pkgs))
|
||||
for _, p := range pkgs {
|
||||
require.NotNil(t, p.TeamID)
|
||||
require.Equal(t, team.ID, *p.TeamID)
|
||||
require.NotZero(t, p.TitleID)
|
||||
names = append(names, p.DisplayName)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// empty incoming: everything is pending deletion; display-name override
|
||||
// used for ins0, title-name fallback for the others; FMA row included.
|
||||
deleted, err := ds.GetSoftwareInstallersPendingDeletion(ctx, &team.ID, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"Cool App", "ins1", "Maintained1"}, displayNames(deleted))
|
||||
|
||||
// bundle-identifier match excludes ins0.
|
||||
deleted, err = ds.GetSoftwareInstallersPendingDeletion(ctx, &team.ID, []fleet.SoftwareTitleIdentifier{
|
||||
{UniqueIdentifier: "com.example.ins0", Source: "apps"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"ins1", "Maintained1"}, displayNames(deleted))
|
||||
|
||||
// name match (no bundle identifier) excludes ins1.
|
||||
deleted, err = ds.GetSoftwareInstallersPendingDeletion(ctx, &team.ID, []fleet.SoftwareTitleIdentifier{
|
||||
{UniqueIdentifier: "ins1", Source: "programs"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"Cool App", "Maintained1"}, displayNames(deleted))
|
||||
|
||||
// source must match too: same unique identifier, wrong source.
|
||||
deleted, err = ds.GetSoftwareInstallersPendingDeletion(ctx, &team.ID, []fleet.SoftwareTitleIdentifier{
|
||||
{UniqueIdentifier: "com.example.ins0", Source: "programs"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"Cool App", "ins1", "Maintained1"}, displayNames(deleted))
|
||||
|
||||
// all matched: nothing pending deletion.
|
||||
deleted, err = ds.GetSoftwareInstallersPendingDeletion(ctx, &team.ID, []fleet.SoftwareTitleIdentifier{
|
||||
{UniqueIdentifier: "com.example.ins0", Source: "apps"},
|
||||
{UniqueIdentifier: "ins1", Source: "programs"},
|
||||
{UniqueIdentifier: "fleet.maintained1", Source: "apps"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, deleted)
|
||||
|
||||
// other teams are not affected: no-team has no installers.
|
||||
deleted, err = ds.GetSoftwareInstallersPendingDeletion(ctx, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, deleted)
|
||||
|
||||
// prediction matches reality: batch-set keeping only ins0 deletes exactly
|
||||
// what was predicted.
|
||||
predicted, err := ds.GetSoftwareInstallersPendingDeletion(ctx, &team.ID, []fleet.SoftwareTitleIdentifier{
|
||||
{UniqueIdentifier: "com.example.ins0", Source: "apps"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
err = ds.BatchSetSoftwareInstallers(ctx, &team.ID, []*fleet.UploadSoftwareInstallerPayload{
|
||||
{
|
||||
InstallScript: "install",
|
||||
InstallerFile: newTFR("installer0"),
|
||||
StorageID: "installer0",
|
||||
Filename: "installer0",
|
||||
Title: "ins0",
|
||||
Source: "apps",
|
||||
Version: "1",
|
||||
UserID: user1.ID,
|
||||
Platform: "darwin",
|
||||
URL: "https://example.com/ins0",
|
||||
ValidatedLabels: &fleet.LabelIdentsWithScope{},
|
||||
BundleIdentifier: "com.example.ins0",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
remaining, err := ds.GetSoftwareInstallers(ctx, team.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, remaining, 1)
|
||||
remainingTitleIDs := map[uint]struct{}{*remaining[0].TitleID: {}}
|
||||
for _, p := range predicted {
|
||||
_, stillThere := remainingTitleIDs[p.TitleID]
|
||||
require.False(t, stillThere, "predicted-deleted title %d (%s) survived the batch set", p.TitleID, p.DisplayName)
|
||||
}
|
||||
require.Len(t, predicted, 2)
|
||||
}
|
||||
|
||||
func testGetSoftwareInstallerMetadataByTeamAndTitleID(t *testing.T, ds *Datastore) {
|
||||
ctx := context.Background()
|
||||
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team 2"})
|
||||
|
||||
Reference in New Issue
Block a user