Pin FMA major version in GitOps (#43053)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**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
This commit is contained in:
Jonathan Katz
2026-04-06 12:36:47 -04:00
committed by GitHub
parent 4d72c203b4
commit b447918b44
9 changed files with 274 additions and 18 deletions
+1
View File
@@ -0,0 +1 @@
- Added ability to pin Fleet-maintained apps to a specific major version in GitOps.
+63 -4
View File
@@ -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
}
@@ -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) {
@@ -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)
}
+13 -7
View File
@@ -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")
}
+3 -2
View File
@@ -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.
+3 -3
View File
@@ -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) {
@@ -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",
)
})
}
+1 -1
View File
@@ -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")
}