From b447918b44a774e85b8a5b988e216431a696eaee Mon Sep 17 00:00:00 2001 From: Jonathan Katz <44128041+jkatz01@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:36:47 -0400 Subject: [PATCH] Pin FMA major version in GitOps (#43053) **Related issue:** Resolves #38988 # 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. - [ ] Timeouts are implemented and retries are limited to avoid infinite loops - [ ] 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) - [ ] QA'd all new/changed functionality manually --- changes/38988-fma-pin-major-version | 1 + ee/server/service/software_installers.go | 67 ++++++++- ee/server/service/software_installers_test.go | 59 ++++++++ server/datastore/mysql/software_installers.go | 2 +- server/datastore/mysql/software_titles.go | 20 ++- server/fleet/datastore.go | 5 +- server/mock/datastore_mock.go | 6 +- server/service/integration_enterprise_test.go | 130 ++++++++++++++++++ server/service/software_titles.go | 2 +- 9 files changed, 274 insertions(+), 18 deletions(-) create mode 100644 changes/38988-fma-pin-major-version diff --git a/changes/38988-fma-pin-major-version b/changes/38988-fma-pin-major-version new file mode 100644 index 0000000000..98052df1b8 --- /dev/null +++ b/changes/38988-fma-pin-major-version @@ -0,0 +1 @@ +- Added ability to pin Fleet-maintained apps to a specific major version in GitOps. diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 97c28ddcc2..3bcae49f5b 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -960,7 +960,7 @@ func (svc *Service) deleteSoftwareInstaller(ctx context.Context, meta *fleet.Sof // delete them. GetFleetMaintainedVersionsByTitleID queries the live DB, so // it will not return the row we just deleted. if meta.TitleID != nil { - cachedVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, meta.TeamID, *meta.TitleID) + cachedVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, meta.TeamID, *meta.TitleID, false) if err != nil { return ctxerr.Wrap(ctx, err, "getting cached FMA versions for cleanup") } @@ -2161,13 +2161,20 @@ func (svc *Service) BatchSetSoftwareInstallers( return requestUUID, nil } +var ( + errNonMajorVersion = errors.New("only the major version can be specified with a caret (^), without including minor and patch versions. For example, \"^32\".") + errMajorVersionNotFound = errors.New("specified major version is not available. Available versions are listed in the Fleet UI under Actions > Edit software.") +) + func (svc *Service) softwareInstallerPayloadFromSlug(ctx context.Context, payload *fleet.SoftwareInstallerPayload, teamID *uint) error { slug := payload.Slug if slug == nil || *slug == "" { return nil } - app, err := svc.ds.GetMaintainedAppBySlug(ctx, *slug, teamID) + // convert nil teamID to 0 to get correct titleID + tmID := ptr.ValOrZero(teamID) + app, err := svc.ds.GetMaintainedAppBySlug(ctx, *slug, &tmID) if err != nil { // Return user-friendly message for generic not found error if fleet.IsNotFound(err) { @@ -2179,11 +2186,63 @@ func (svc *Service) softwareInstallerPayloadFromSlug(ctx context.Context, payloa } return err } - fma, err := maintained_apps.Hydrate(ctx, app, payload.RollbackVersion, teamID, svc.ds) + + majorVersionString, usesCaret := strings.CutPrefix(payload.RollbackVersion, "^") + if usesCaret { + if len(majorVersionString) == 0 { + return ctxerr.Wrap(ctx, errors.New("no version number provided"), "reading Fleet-maintained app pinned version") + } + if parts := strings.Split(payload.RollbackVersion, "."); len(parts) > 1 { + return fleet.NewUserMessageError(errNonMajorVersion, http.StatusBadRequest) + } + // unset rollback version to avoid getting a cached installer + payload.RollbackVersion = "" + } + + _, err = maintained_apps.Hydrate(ctx, app, payload.RollbackVersion, teamID, svc.ds) if err != nil { return err } + if usesCaret { + downloadedSemVer, err := fleet.VersionToSemverVersion(app.Version) + if err != nil { + return ctxerr.Wrap(ctx, err, "extracting semver version") + } + + majorVersion, err := fleet.VersionToSemverVersion(majorVersionString) + if err != nil { + return ctxerr.Wrap(ctx, err, "extracting pinnged major version") + } + + if downloadedSemVer.Major() != majorVersion.Major() { + // We cannot use the FMA we just got the manifest for since it is on a different major + // version, so we try to find the latest cached version and use that instead. + if app.TitleID == nil { + return fleet.NewUserMessageError(errMajorVersionNotFound, http.StatusNotFound) + } + versions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, *app.TitleID, true) + if err != nil { + return fleet.NewUserMessageError(errMajorVersionNotFound, http.StatusNotFound) + } + + // This is a bit inefficient as we are duplicating strings for categories and install/uninstall scripts, + // but it can be optimized in softwareBatchUpload if it accepted only passing category and script content IDs. + installer, err := svc.ds.GetCachedFMAInstallerMetadata(ctx, teamID, app.ID, versions[0].Version) + if err != nil { + return ctxerr.Wrap(ctx, err, "getting software installer") + } + + app.Version = installer.Version + app.InstallerURL = installer.InstallerURL + app.SHA256 = installer.SHA256 + app.InstallScript = installer.InstallScript + app.UninstallScript = installer.UninstallScript + app.Categories = installer.Categories + app.PatchQuery = installer.PatchQuery + } + } + payload.URL = app.InstallerURL if app.SHA256 != noCheckHash { payload.SHA256 = app.SHA256 @@ -2199,7 +2258,7 @@ func (svc *Service) softwareInstallerPayloadFromSlug(ctx context.Context, payloa if len(payload.Categories) == 0 { payload.Categories = app.Categories } - payload.MaintainedApp.PatchQuery = fma.PatchQuery + payload.MaintainedApp.PatchQuery = app.PatchQuery return nil } diff --git a/ee/server/service/software_installers_test.go b/ee/server/service/software_installers_test.go index 66120f5f4b..4401f2e686 100644 --- a/ee/server/service/software_installers_test.go +++ b/ee/server/service/software_installers_test.go @@ -477,6 +477,65 @@ func TestSoftwareInstallerPayloadFromSlug(t *testing.T) { assert.Empty(t, payload.InstallScript) assert.Empty(t, payload.UninstallScript) assert.False(t, payload.FleetMaintained) + + ds.GetMaintainedAppBySlugFunc = func(ctx context.Context, slug string, teamID *uint) (*fleet.MaintainedApp, error) { + return &fleet.MaintainedApp{ + ID: 1, + Name: "1Password", + Platform: "darwin", + UniqueIdentifier: "com.1password.1password", + Slug: "1password/darwin", + TitleID: new(uint(1)), + }, nil + } + + ds.GetFleetMaintainedVersionsByTitleIDFunc = func(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) { + return []fleet.FleetMaintainedVersion{{ID: 1, Version: "26.0.0"}}, nil + } + + ds.GetCachedFMAInstallerMetadataFunc = func(ctx context.Context, teamID *uint, fmaID uint, version string) (*fleet.MaintainedApp, error) { + return &fleet.MaintainedApp{ + ID: 1, + Name: "1Password", + Platform: "darwin", + UniqueIdentifier: "com.1password.1password", + Slug: "1password/darwin", + }, nil + } + + versionPinValidationTests := []struct { + name string + version string + wantErr string + }{ + { + name: "valid", + version: "^26", + }, + { + name: "no version", + version: "^", + wantErr: "no version number provided", + }, + { + name: "invalid version", + version: "^26.0", + wantErr: errNonMajorVersion.Error(), + }, + } + + for _, vt := range versionPinValidationTests { + t.Run(vt.name, func(t *testing.T) { + payload := fleet.SoftwareInstallerPayload{Slug: ptr.String("1password/darwin"), RollbackVersion: vt.version} + err = svc.softwareInstallerPayloadFromSlug(context.Background(), &payload, nil) + if vt.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, vt.wantErr) + } else { + require.NoError(t, err) + } + }) + } } func TestGetInHouseAppManifest(t *testing.T) { diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index f8f7535c64..1cb6ef7280 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -2883,7 +2883,7 @@ WHERE // Evict old FMA versions beyond the max per title per team. // Always keep the active installer; fill remaining slots with // the most recent versions, evict everything else. - fmaVersions, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, tx, []uint{titleID}, globalOrTeamID) + fmaVersions, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, tx, []uint{titleID}, globalOrTeamID, false) if err != nil { return ctxerr.Wrapf(ctx, err, "list FMA installer versions for eviction for %q", installer.Filename) } diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index 25b4ada8ba..3753e31fdb 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -513,7 +513,7 @@ func (ds *Datastore) processSoftwareTitleResults( } } if len(fmaTitleIDs) > 0 { - fmaVersions, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, ds.reader(ctx), fmaTitleIDs, *opt.TeamID) + fmaVersions, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, ds.reader(ctx), fmaTitleIDs, *opt.TeamID, false) if err != nil { return nil, 0, nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions") } @@ -964,8 +964,8 @@ func countSoftwareTitlesOptimized(opts fleet.SoftwareTitleListOptions) string { // GetFleetMaintainedVersionsByTitleID returns all cached versions of a fleet-maintained app // for the given title and team. -func (ds *Datastore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint) ([]fleet.FleetMaintainedVersion, error) { - result, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, ds.reader(ctx), []uint{titleID}, ptr.ValOrZero(teamID)) +func (ds *Datastore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) { + result, err := ds.getFleetMaintainedVersionsByTitleIDs(ctx, ds.reader(ctx), []uint{titleID}, ptr.ValOrZero(teamID), byVersion) if err != nil { return nil, err } @@ -974,17 +974,23 @@ func (ds *Datastore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, te // getFleetMaintainedVersionsByTitleIDs returns all cached versions of fleet-maintained apps // for the given title IDs and team, keyed by title ID. -func (ds *Datastore) getFleetMaintainedVersionsByTitleIDs(ctx context.Context, q sqlx.QueryerContext, titleIDs []uint, teamID uint) (map[uint][]fleet.FleetMaintainedVersion, error) { +func (ds *Datastore) getFleetMaintainedVersionsByTitleIDs(ctx context.Context, q sqlx.QueryerContext, titleIDs []uint, teamID uint, byVersion bool) (map[uint][]fleet.FleetMaintainedVersion, error) { if len(titleIDs) == 0 { return nil, nil } - query, args, err := sqlx.In(` + query := ` SELECT si.id, si.version, si.title_id FROM software_installers si WHERE si.title_id IN (?) AND si.global_or_team_id = ? AND si.fleet_maintained_app_id IS NOT NULL - ORDER BY si.title_id, si.uploaded_at DESC - `, titleIDs, teamID) + ` + if byVersion { + query += ` ORDER BY si.version DESC` + } else { + query += ` ORDER BY si.title_id, si.uploaded_at DESC` + } + + query, args, err := sqlx.In(query, titleIDs, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "build fleet maintained versions query") } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 967fbc86fe..eb4a2a61a4 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -2213,8 +2213,9 @@ type Datastore interface { GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*SoftwareInstaller, error) // GetFleetMaintainedVersionsByTitleID returns all cached versions of a - // fleet-maintained app for the given title and team. - GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint) ([]FleetMaintainedVersion, error) + // fleet-maintained app for the given title and team. If byVersion is true + // the versions will be sorted by the version string. + GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]FleetMaintainedVersion, error) // HasFMAInstallerVersion returns true if the given FMA version is already // cached as a software installer for the given team. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 9ce18ab2a2..15528f700f 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1401,7 +1401,7 @@ type ValidateOrbitSoftwareInstallerAccessFunc func(ctx context.Context, hostID u type GetSoftwareInstallerMetadataByTeamAndTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, withScriptContents bool) (*fleet.SoftwareInstaller, error) -type GetFleetMaintainedVersionsByTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint) ([]fleet.FleetMaintainedVersion, error) +type GetFleetMaintainedVersionsByTitleIDFunc func(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) type HasFMAInstallerVersionFunc func(ctx context.Context, teamID *uint, fmaID uint, version string) (bool, error) @@ -9430,11 +9430,11 @@ func (s *DataStore) GetSoftwareInstallerMetadataByTeamAndTitleID(ctx context.Con return s.GetSoftwareInstallerMetadataByTeamAndTitleIDFunc(ctx, teamID, titleID, withScriptContents) } -func (s *DataStore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint) ([]fleet.FleetMaintainedVersion, error) { +func (s *DataStore) GetFleetMaintainedVersionsByTitleID(ctx context.Context, teamID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) { s.mu.Lock() s.GetFleetMaintainedVersionsByTitleIDFuncInvoked = true s.mu.Unlock() - return s.GetFleetMaintainedVersionsByTitleIDFunc(ctx, teamID, titleID) + return s.GetFleetMaintainedVersionsByTitleIDFunc(ctx, teamID, titleID, byVersion) } func (s *DataStore) HasFMAInstallerVersion(ctx context.Context, teamID *uint, fmaID uint, version string) (bool, error) { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 344d6b0cad..999bdf6139 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -27945,3 +27945,133 @@ func getFleetMaintainedAppID(t *testing.T, ds *mysql.Datastore, slug string) uin }) return id } + +func (s *integrationEnterpriseTestSuite) TestPinMajorVersion() { + t := s.T() + + resetFMAState := func(state *fmaTestState, version string, installerBytes []byte, patchQuery string) { + state.version = version + state.installerBytes = installerBytes + state.ComputeSHA(installerBytes) + state.patchQuery = patchQuery + } + + t.Run("pin major version", func(t *testing.T) { + teamName := "" + states := make(map[string]*fmaTestState, 1) + states["/zoom/windows.json"] = &fmaTestState{ + version: "1.0", + installerBytes: []byte("xyz"), + installerPath: "/zoom.msi", + } + startFMAServers(t, s.ds, states) + + var resp batchSetSoftwareInstallersResponse + s.DoJSON("POST", "/api/latest/fleet/software/batch", + batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{{Slug: ptr.String("zoom/windows")}}, TeamName: teamName}, + http.StatusAccepted, &resp, + "team_name", teamName, "team_id", "0", + ) + waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, teamName, resp.RequestUUID) + + var listTitlesResp listSoftwareTitlesResponse + s.DoJSON("GET", "/api/latest/fleet/software/titles", nil, http.StatusOK, &listTitlesResp, "team_id", "0") + require.Len(t, listTitlesResp.SoftwareTitles, 1) + require.Equal(t, "zoom.msi", listTitlesResp.SoftwareTitles[0].SoftwarePackage.Name) + require.Equal(t, "1.0", listTitlesResp.SoftwareTitles[0].SoftwarePackage.Version) + titleID := listTitlesResp.SoftwareTitles[0].ID + + // pin version to ^1, call batch, should get 1.0 + s.DoJSON("POST", "/api/latest/fleet/software/batch", + batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{{Slug: ptr.String("zoom/windows"), RollbackVersion: "^1"}}, TeamName: teamName}, + http.StatusAccepted, &resp, + "team_name", teamName, "team_id", "0", + ) + waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, teamName, resp.RequestUUID) + + var titleResp getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", "0") + require.NotNil(t, titleResp.SoftwareTitle) + require.Equal(t, "1.0", titleResp.SoftwareTitle.SoftwarePackage.Version) + + // update manifest to 1.1, FleetMaintainedVersions should have 1.0, 1.1 + // installer version should be 1.1, while still pinned to ^1 + resetFMAState(states["/zoom/windows.json"], "1.1", []byte("abc"), "") + + s.DoJSON("POST", "/api/latest/fleet/software/batch", + batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{{Slug: ptr.String("zoom/windows"), RollbackVersion: "^1"}}, TeamName: teamName}, + http.StatusAccepted, &resp, + "team_name", teamName, "team_id", "0", + ) + waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, teamName, resp.RequestUUID) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", "0") + require.NotNil(t, titleResp.SoftwareTitle) + require.Len(t, titleResp.SoftwareTitle.SoftwarePackage.FleetMaintainedVersions, 2) + require.Equal(t, "1.1", titleResp.SoftwareTitle.SoftwarePackage.FleetMaintainedVersions[0].Version) + require.Equal(t, "1.0", titleResp.SoftwareTitle.SoftwarePackage.FleetMaintainedVersions[1].Version) + require.Equal(t, "1.1", titleResp.SoftwareTitle.SoftwarePackage.Version) + + // pin version to 1.0? then pin to ^1, installer should be 1.1. if fleet keeps enough versions we need to test this. + // maybe unnecessary test, can remove to speed it up + + s.DoJSON("POST", "/api/latest/fleet/software/batch", + batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{{Slug: ptr.String("zoom/windows"), RollbackVersion: "1.0"}}, TeamName: teamName}, + http.StatusAccepted, &resp, + "team_name", teamName, "team_id", "0", + ) + waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, teamName, resp.RequestUUID) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", "0") + require.NotNil(t, titleResp.SoftwareTitle) + require.Equal(t, "1.0", titleResp.SoftwareTitle.SoftwarePackage.Version) + + s.DoJSON("POST", "/api/latest/fleet/software/batch", + batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{{Slug: ptr.String("zoom/windows"), RollbackVersion: "^1"}}, TeamName: teamName}, + http.StatusAccepted, &resp, + "team_name", teamName, "team_id", "0", + ) + waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, teamName, resp.RequestUUID) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", "0") + require.NotNil(t, titleResp.SoftwareTitle) + require.Equal(t, "1.1", titleResp.SoftwareTitle.SoftwarePackage.Version) + + // update manifesst to 2.0, installer should be 1.1 + resetFMAState(states["/zoom/windows.json"], "2.0", []byte("abc"), "") + + s.DoJSON("POST", "/api/latest/fleet/software/batch", + batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{{Slug: ptr.String("zoom/windows"), RollbackVersion: "^1"}}, TeamName: teamName}, + http.StatusAccepted, &resp, + "team_name", teamName, "team_id", "0", + ) + waitBatchSetSoftwareInstallersCompleted(t, &s.withServer, teamName, resp.RequestUUID) + + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), nil, http.StatusOK, &titleResp, "team_id", "0") + require.NotNil(t, titleResp.SoftwareTitle) + require.Len(t, titleResp.SoftwareTitle.SoftwarePackage.FleetMaintainedVersions, 2) + require.Equal(t, "1.1", titleResp.SoftwareTitle.SoftwarePackage.FleetMaintainedVersions[0].Version) + require.Equal(t, "1.0", titleResp.SoftwareTitle.SoftwarePackage.FleetMaintainedVersions[1].Version) + require.Equal(t, "1.1", titleResp.SoftwareTitle.SoftwarePackage.Version) + }) + + // Test pinning ^1 for the first time when only 2.0 is available from manifest + t.Run("add installer first time with version pin", func(t *testing.T) { + teamName := "" + states := make(map[string]*fmaTestState, 1) + // use a different installer that isn't available yet + states["/1password/darwin.json"] = &fmaTestState{ + version: "2.0", + installerBytes: []byte("xyz"), + installerPath: "/1password.pkg", + } + startFMAServers(t, s.ds, states) + + var resp batchSetSoftwareInstallersResponse + s.DoJSON("POST", "/api/latest/fleet/software/batch", + batchSetSoftwareInstallersRequest{Software: []*fleet.SoftwareInstallerPayload{{Slug: ptr.String("1password/darwin"), RollbackVersion: "^1"}}, TeamName: teamName}, + http.StatusNotFound, &resp, + "team_name", teamName, "team_id", "0", + ) + }) +} diff --git a/server/service/software_titles.go b/server/service/software_titles.go index 37e0d24349..7ea03ddc6b 100644 --- a/server/service/software_titles.go +++ b/server/service/software_titles.go @@ -203,7 +203,7 @@ func (svc *Service) SoftwareTitleByID(ctx context.Context, id uint, teamID *uint // Populate FleetMaintainedVersions if this is an FMA if meta != nil && meta.FleetMaintainedAppID != nil { - fmaVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, id) + fmaVersions, err := svc.ds.GetFleetMaintainedVersionsByTitleID(ctx, teamID, id, false) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get fleet maintained versions") }