Use list FMA endpoint in generate-gitops to match FMAs by ID (#42483)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #39842
Replaces the call to
`maintained_apps.FetchAppsList(context.Background())` which downloads
the apps.json list from github to the list Fleet-maintained apps
endpoint. This is so we can match apps by their Fleet-maintained app ID
instead of by name which can cause problems when a Windows FMA is
associated to a title with the wrong name (e.g. title is `7-Zip 23.01`
instead of the current FMA's name `7-Zip 25.01`).

# 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.

- [ ] 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.
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually
This commit is contained in:
Jonathan Katz
2026-03-26 15:52:28 -04:00
committed by GitHub
parent ee822eb207
commit 6618282baa
6 changed files with 59 additions and 16 deletions
+1
View File
@@ -0,0 +1 @@
- Fixed a bug where `fleetctl generate-gitops` failed if a Fleet-maintained app was associated to a software title with a different name (e.g. names with different versions).
+10 -15
View File
@@ -89,6 +89,7 @@ type generateGitopsClient interface {
GetCertificateAuthoritiesSpec(includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error)
GetCertificateTemplates(teamID string) ([]*fleet.CertificateTemplateResponseSummary, error)
GetFleetMaintainedApp(id uint) (*fleet.MaintainedApp, error)
ListFleetMaintainedApps(teamID uint) ([]fleet.MaintainedApp, error)
}
// Given a struct type and a field name, return the JSON field name.
@@ -1687,13 +1688,13 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint,
packages := make([]map[string]any, 0)
appStoreApps := make([]map[string]any, 0)
fmas := make([]map[string]any, 0)
var appsList *maintained_apps.AppsList
var appsList []fleet.MaintainedApp
// in-house apps generate two software titles for the same gitops entry: one
// for iOS and one for iPadOS. Use this set to deduplicate them (by filename,
// which is unique for a given team and platform).
dedupeInHouseAppsByFilename := make(map[string]struct{})
var byUniqueID map[string]string
var byFMAID map[uint]string
for _, sw := range software {
softwareSpec := make(map[string]interface{})
switch {
@@ -1760,28 +1761,22 @@ func (cmd *GenerateGitopsCommand) generateSoftware(filePath string, teamID uint,
var fmaInstallScriptModified, fmaUninstallScriptModified bool
if softwareTitle.SoftwarePackage.FleetMaintainedAppID != nil {
if byUniqueID == nil {
if byFMAID == nil {
if appsList == nil {
var err error
appsList, err = maintained_apps.FetchAppsList(context.Background())
// currently, the list FMA endpoint has no default pagination
appsList, err = cmd.Client.ListFleetMaintainedApps(teamID)
if err != nil {
return nil, err
}
}
byUniqueID = make(map[string]string, len(appsList.Apps))
for _, a := range appsList.Apps {
byUniqueID[a.UniqueIdentifier] = a.Slug
byFMAID = make(map[uint]string, len(appsList))
for _, a := range appsList {
byFMAID[a.ID] = a.Slug
}
}
// Look up slug by bundle identifier (macOS) or software title name (Windows).
// Windows FMAs don't have a bundle identifier; their unique_identifier
// in the manifest is the program name (e.g., "1Password").
lookupKey := ptr.ValOrZero(softwareTitle.BundleIdentifier)
if lookupKey == "" {
lookupKey = softwareTitle.Name
}
slug = byUniqueID[lookupKey]
slug = byFMAID[*softwareTitle.SoftwarePackage.FleetMaintainedAppID]
fma, err := maintained_apps.Hydrate(context.Background(), &fleet.MaintainedApp{
ID: *softwareTitle.SoftwarePackage.FleetMaintainedAppID,
Slug: slug,
+32 -1
View File
@@ -311,6 +311,17 @@ func (MockClient) ListSoftwareTitles(query string) ([]fleet.SoftwareTitleListRes
FleetMaintainedAppID: ptr.Uint(2),
},
},
{
ID: 10,
Name: "Version Locked Name 0.1",
HashSHA256: ptr.String("win-fma-3-package-hash"),
SoftwarePackage: &fleet.SoftwarePackageOrApp{
Name: "my-fma.msi",
Platform: "windows",
Version: "1",
FleetMaintainedAppID: ptr.Uint(3),
},
},
}, nil
case "available_for_install=1&fleet_id=0":
return []fleet.SoftwareTitleListResult{}, nil
@@ -323,6 +334,14 @@ func (MockClient) GetFleetMaintainedApp(id uint) (*fleet.MaintainedApp, error) {
return &fleet.MaintainedApp{Slug: "foo/darwin"}, nil
}
func (MockClient) ListFleetMaintainedApps(teamID uint) ([]fleet.MaintainedApp, error) {
return []fleet.MaintainedApp{
{ID: 1, Slug: "fma1/darwin", Name: "My FMA", Platform: "darwin", UniqueIdentifier: "com.my.fma"},
{ID: 2, Slug: "fma2/windows", Name: "My Windows FMA", Platform: "windows", UniqueIdentifier: "My Windows FMA"},
{ID: 3, Slug: "fma3/windows", Name: "Version Locked Name 2.0", Platform: "windows", UniqueIdentifier: "Version Locked Name 2.0"},
}, nil
}
func (MockClient) GetPolicies(teamID *uint) ([]*fleet.Policy, error) {
if teamID == nil {
return []*fleet.Policy{
@@ -567,6 +586,18 @@ func (MockClient) GetSoftwareTitleByID(ID uint, teamID *uint) (*fleet.SoftwareTi
},
IconUrl: ptr.String("/api/icon5.png"),
}, nil
case 10:
return &fleet.SoftwareTitle{
ID: 9,
Name: "Version Locked Name 0.1",
SoftwarePackage: &fleet.SoftwareInstaller{
InstallScript: "install",
UninstallScript: "uninstall",
SelfService: true,
Platform: "windows",
FleetMaintainedAppID: ptr.Uint(3),
},
}, nil
default:
return nil, errors.New("software title not found")
}
@@ -834,7 +865,7 @@ func compareDirs(t *testing.T, sourceDir, targetDir string) {
func configureFMAManifestServer(t *testing.T) {
manifestServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "apps.json") {
data := json.RawMessage(`{"version": 2, "apps": [{"name": "My FMA", "slug": "fma1/darwin", "platform": "darwin", "unique_identifier": "com.my.fma"}, {"name": "My Windows FMA", "slug": "fma2/windows", "platform": "windows", "unique_identifier": "My Windows FMA"}]}`)
data := json.RawMessage(`{"version": 2, "apps": [{"name": "My FMA", "slug": "fma1/darwin", "platform": "darwin", "unique_identifier": "com.my.fma"}, {"name": "My Windows FMA", "slug": "fma2/windows", "platform": "windows", "unique_identifier": "My Windows FMA"}, {"name": "Version Locked Name 2.0", "slug": "fma3/windows", "platform": "windows", "unique_identifier": "Version Locked Name 2.0"}]}`)
err := json.NewEncoder(w).Encode(data)
require.NoError(t, err)
return
@@ -34,6 +34,8 @@ fleet_maintained_apps:
- Label A
- Label B
self_service: true
- slug: fma3/windows
self_service: true
app_store_apps:
- app_store_id: "1234567890"
labels_exclude_any:
@@ -171,6 +171,8 @@ software:
- Label B
self_service: true
slug: fma2/windows
- self_service: true
slug: fma3/windows
packages:
- categories:
- Browsers
+12
View File
@@ -267,3 +267,15 @@ func (c *Client) GetFleetMaintainedApp(id uint) (*fleet.MaintainedApp, error) {
}
return responseBody.FleetMaintainedApp, nil
}
func (c *Client) ListFleetMaintainedApps(teamID uint) ([]fleet.MaintainedApp, error) {
verb, path := "GET", "/api/latest/fleet/software/fleet_maintained_apps"
query := fmt.Sprintf("fleet_id=%d", teamID)
var responseBody listFleetMaintainedAppsResponse
err := c.authenticatedRequestWithQuery(nil, verb, path, &responseBody, query)
if err != nil {
return nil, err
}
return responseBody.FleetMaintainedApps, nil
}