Files
fleet/ee/server/service/maintained_apps_auto_update_test.go
Carlo 7cb2399700 Redirect FMA installs to the active version after auto-update (#49525)
**Related issue:** Resolves #49495

Redirects queued Fleet-maintained app installs to the newly-active
installer (canceling already-dispatched ones) atomically when an
auto-update or pin change flips the active version, and re-resolves
install retries to the active installer, so a host no longer installs a
superseded cached version.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Fleet-maintained app installs now consistently use the currently
active version after automatic promotions, preventing stale installer
targeting.
* Queued installs tied to an older promoted installer are redirected to
the newly active installer instead of being canceled.
* Install retry flows now re-resolve to the active installer at retry
time, avoiding stale retries after version changes.
* **Tests**
* Added datastore coverage for active-installer redirection and updated
retry tests to verify the correct installer ID is used.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-18 06:01:45 -04:00

161 lines
5.3 KiB
Go

package service
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"testing"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/stretchr/testify/require"
)
func TestAutoUpdateFleetMaintainedApps(t *testing.T) {
// Cached versions for the title, semver-sorted newest-first (as the real
// datastore returns them with byVersion=true).
versions := []fleet.FleetMaintainedVersion{
{ID: 12, Version: "149.0.2"},
{ID: 11, Version: "147.0.9"},
{ID: 9, Version: "148.0.1"},
{ID: 8, Version: "147.0.5"},
}
teamID := uint(1)
candidate := func(activeID uint, activeVer string) fleet.FMAAutoUpdateCandidate {
return fleet.FMAAutoUpdateCandidate{
TeamID: &teamID,
TitleID: 1,
InstallerID: activeID,
Version: activeVer,
Slug: "chrome/darwin",
}
}
cases := []struct {
name string
// pin: nil => no pin row (Latest); otherwise the stored expression.
pin *string
active fleet.FMAAutoUpdateCandidate
cached []fleet.FleetMaintainedVersion
wantFlip bool
wantActiveID uint // installer ID bound active (when wantFlip)
}{
{
name: "Latest advances to newest cached",
pin: nil,
active: candidate(9, "148.0.1"),
cached: versions,
wantFlip: true,
wantActiveID: 12,
},
{
name: "Latest already on newest is a no-op",
pin: nil,
active: candidate(12, "149.0.2"),
cached: versions,
wantFlip: false,
},
{
name: "caret advances within major",
pin: new("^147"),
active: candidate(8, "147.0.5"),
cached: versions,
wantFlip: true,
wantActiveID: 11, // 147.0.9, newest within major 147
},
{
name: "caret never crosses major when no in-major version is cached",
pin: new("^147"),
active: candidate(12, "149.0.2"),
cached: []fleet.FleetMaintainedVersion{{ID: 12, Version: "149.0.2"}},
wantFlip: false,
},
{
name: "literal pin never advances",
pin: new("148.0.1"),
active: candidate(9, "148.0.1"),
cached: versions,
wantFlip: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ds := new(mock.Store)
ds.ListFleetMaintainedAppActiveInstallersFunc = func(ctx context.Context) ([]fleet.FMAAutoUpdateCandidate, error) {
return []fleet.FMAAutoUpdateCandidate{tc.active}, nil
}
ds.GetPinnedVersionFunc = func(ctx context.Context, tmID *uint, titleID uint) (*string, error) {
if tc.pin == nil {
return nil, sql.ErrNoRows
}
return tc.pin, nil
}
ds.GetFleetMaintainedVersionsByTitleIDFunc = func(ctx context.Context, tmID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) {
return tc.cached, nil
}
var gotActiveID uint
ds.SetFleetMaintainedAppActiveInstallerFunc = func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload, activeInstallerID uint) error {
gotActiveID = activeInstallerID
// The cron must never write pin state, or it could clobber a
// concurrent admin pin change.
require.Nil(t, payload.PinnedVersion, "cron must not write the pin row")
return nil
}
// nil store: promote-only mode (no upstream download), exercising
// advancement among already-cached versions.
err := AutoUpdateFleetMaintainedApps(context.Background(), ds, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err)
require.Equal(t, tc.wantFlip, ds.SetFleetMaintainedAppActiveInstallerFuncInvoked)
if tc.wantFlip {
require.Equal(t, tc.wantActiveID, gotActiveID)
}
// A literal pin short-circuits before querying cached versions.
if tc.pin != nil && *tc.pin != "" && (*tc.pin)[0] != '^' {
require.False(t, ds.GetFleetMaintainedVersionsByTitleIDFuncInvoked)
}
})
}
}
func TestAutoUpdateFleetMaintainedAppsContinuesPastError(t *testing.T) {
ds := new(mock.Store)
teamID := uint(1)
ds.ListFleetMaintainedAppActiveInstallersFunc = func(ctx context.Context) ([]fleet.FMAAutoUpdateCandidate, error) {
return []fleet.FMAAutoUpdateCandidate{
{TeamID: &teamID, TitleID: 1, InstallerID: 9, Slug: "bad/darwin"},
{TeamID: &teamID, TitleID: 2, InstallerID: 20, Slug: "good/darwin"},
}, nil
}
ds.GetPinnedVersionFunc = func(ctx context.Context, tmID *uint, titleID uint) (*string, error) {
if titleID == 1 {
return nil, errors.New("boom")
}
return nil, sql.ErrNoRows
}
ds.GetFleetMaintainedVersionsByTitleIDFunc = func(ctx context.Context, tmID *uint, titleID uint, byVersion bool) ([]fleet.FleetMaintainedVersion, error) {
return []fleet.FleetMaintainedVersion{{ID: 21, Version: "2.0.0"}}, nil
}
var flippedTitle uint
ds.SetFleetMaintainedAppActiveInstallerFunc = func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload, activeInstallerID uint) error {
flippedTitle = payload.TitleID
return nil
}
ds.ProcessInstallerUpdateSideEffectsFunc = func(ctx context.Context, installerID uint, wasMetadataUpdated, wasPackageUpdated bool) error {
return nil
}
// The first candidate errors; the run must still process the second.
err := AutoUpdateFleetMaintainedApps(context.Background(), ds, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err)
require.True(t, ds.SetFleetMaintainedAppActiveInstallerFuncInvoked)
require.Equal(t, uint(2), flippedTitle)
}