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:
Scott Gress
2026-06-04 13:11:49 -05:00
committed by GitHub
parent 0e30fbd5ab
commit 07df7c5cfd
19 changed files with 590 additions and 44 deletions
@@ -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"})