Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

List Available Fleet Managed Apps #22059

Merged
merged 20 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
eb34723
Initial outline of list fma
dantecatalfamo Sep 12, 2024
9384161
Typos
dantecatalfamo Sep 12, 2024
1d1df74
Change join format, add pagination, proper service method (no authz)
dantecatalfamo Sep 16, 2024
b72f6d7
Typos and interface mismatches
dantecatalfamo Sep 16, 2024
a868326
Generate mock
dantecatalfamo Sep 16, 2024
55c2183
Merge branch 'feat-fleet-app-library' into fleet-managed-apps-list-apps
dantecatalfamo Sep 16, 2024
3c3939b
Move functions to existing files, match platform on join as well
dantecatalfamo Sep 17, 2024
e2ee658
Updating tests, change function signature
dantecatalfamo Sep 17, 2024
6519095
Merge branch 'feat-fleet-app-library' into fleet-managed-apps-list-apps
dantecatalfamo Sep 17, 2024
17040bd
Query works, tests work
dantecatalfamo Sep 17, 2024
8979533
Add changes/
dantecatalfamo Sep 17, 2024
7a53c12
Feet maintained app id is an integer not a string
dantecatalfamo Sep 18, 2024
3f0bcaf
Missing err check
dantecatalfamo Sep 18, 2024
14e7690
Change fleet managed to Fleet-maintained
dantecatalfamo Sep 18, 2024
d2669a6
Use existing type for maintained apps list, add authz check
dantecatalfamo Sep 18, 2024
b510c95
Merge branch 'feat-fleet-app-library' into fleet-managed-apps-list-apps
dantecatalfamo Sep 18, 2024
a516667
Use returned value from upsert now in tests
dantecatalfamo Sep 18, 2024
b81b242
Add integration test
dantecatalfamo Sep 18, 2024
b8264f2
Fix other broken test
dantecatalfamo Sep 19, 2024
bf4cd3d
Move endpoint in handler
dantecatalfamo Sep 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/21777-list-fleet-manated-apps
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add API endpoint to list team available Fleet-maintained apps
16 changes: 16 additions & 0 deletions ee/server/service/software.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package service
import (
"context"

"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
)

Expand All @@ -16,3 +17,18 @@ func (svc *Service) SoftwareByID(ctx context.Context, id uint, teamID *uint, _ b
// reuse SoftwareByID, but include cve scores in premium version
return svc.Service.SoftwareByID(ctx, id, teamID, true)
}

func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID uint, opts fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) {
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{
TeamID: &teamID,
}, fleet.ActionRead); err != nil {
return nil, nil, err
}

avail, meta, err := svc.ds.ListAvailableFleetMaintainedApps(ctx, teamID, opts)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "listing available fleet managed apps")
}

return avail, meta, nil
}
48 changes: 48 additions & 0 deletions server/datastore/mysql/maintained_apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,51 @@ WHERE

return &app, nil
}

func (ds *Datastore) ListAvailableFleetMaintainedApps(ctx context.Context, teamID uint, opt fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) {
stmt := `
SELECT
fla.id,
fla.name,
fla.version,
fla.platform
FROM
fleet_library_apps fla
WHERE NOT EXISTS (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice! so this was better than the subquery + where?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually never ended up testing the performance, although I assume this solution will work well.

SELECT
1
FROM
software_titles st
LEFT JOIN
software_installers si
ON si.title_id = st.id
LEFT JOIN
vpp_apps va
ON va.title_id = st.id
LEFT JOIN
vpp_apps_teams vat
ON vat.adam_id = va.adam_id
WHERE
st.bundle_identifier = fla.bundle_identifier
AND (
(si.platform = fla.platform AND si.team_id = ?)
OR
(va.platform = fla.platform AND vat.team_id = ?)
)
)`

stmtPaged, args := appendListOptionsWithCursorToSQL(stmt, []any{teamID, teamID}, &opt)

var avail []fleet.MaintainedApp
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &avail, stmtPaged, args...); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "selecting available fleet managed apps")
}

meta := &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0}
if len(avail) > int(opt.PerPage) {
meta.HasNextResults = true
avail = avail[:len(avail)-1]
}

return avail, meta, nil
}
244 changes: 239 additions & 5 deletions server/datastore/mysql/maintained_apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-kit/kit/log"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
Expand All @@ -21,6 +22,7 @@ func TestMaintainedApps(t *testing.T) {
}{
{"UpsertMaintainedApps", testUpsertMaintainedApps},
{"IngestWithBrew", testIngestWithBrew},
{"ListAvailableApps", testListAvailableApps},
{"GetMaintainedAppByID", testGetMaintainedAppByID},
}

Expand All @@ -35,8 +37,8 @@ func TestMaintainedApps(t *testing.T) {
func testUpsertMaintainedApps(t *testing.T, ds *Datastore) {
ctx := context.Background()

listSavedApps := func() []*fleet.MaintainedApp {
var apps []*fleet.MaintainedApp
listSavedApps := func() []fleet.MaintainedApp {
var apps []fleet.MaintainedApp
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &apps, "SELECT name, version, platform FROM fleet_library_apps ORDER BY token")
})
Expand All @@ -61,9 +63,9 @@ func testUpsertMaintainedApps(t *testing.T, ds *Datastore) {
require.NoError(t, err)

// change the expected app data for figma
for _, app := range expectedApps {
if app.Name == "Figma" {
app.Version = "999.9.9"
for idx := range expectedApps {
if expectedApps[idx].Name == "Figma" {
expectedApps[idx].Version = "999.9.9"
break
}
}
Expand All @@ -87,6 +89,238 @@ func testIngestWithBrew(t *testing.T, ds *Datastore) {
require.ElementsMatch(t, expectedTokens, actualTokens)
}

func testListAvailableApps(t *testing.T, ds *Datastore) {
ctx := context.Background()

user := test.NewUser(t, ds, "Zaphod Beeblebrox", "zaphod@example.com", true)
team1, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team 1"})
require.NoError(t, err)
team2, err := ds.NewTeam(ctx, &fleet.Team{Name: "Team 2"})
require.NoError(t, err)

maintained1, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
Name: "Maintained1",
Token: "maintained1",
Version: "1.0.0",
Platform: fleet.MacOSPlatform,
InstallerURL: "http://example.com/main1",
SHA256: "DEADBEEF",
BundleIdentifier: "fleet.maintained1",
InstallScript: "echo installed",
UninstallScript: "echo uninstalled",
})
require.NoError(t, err)
maintained2, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
Name: "Maintained2",
Token: "maintained2",
Version: "1.0.0",
Platform: fleet.MacOSPlatform,
InstallerURL: "http://example.com/main1",
SHA256: "DEADBEEF",
BundleIdentifier: "fleet.maintained2",
InstallScript: "echo installed",
UninstallScript: "echo uninstalled",
})
require.NoError(t, err)
maintained3, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
Name: "Maintained3",
Token: "maintained3",
Version: "1.0.0",
Platform: fleet.MacOSPlatform,
InstallerURL: "http://example.com/main1",
SHA256: "DEADBEEF",
BundleIdentifier: "fleet.maintained3",
InstallScript: "echo installed",
UninstallScript: "echo uninstalled",
})
require.NoError(t, err)

expectedApps := []fleet.MaintainedApp{
{
ID: maintained1.ID,
Name: maintained1.Name,
Version: maintained1.Version,
Platform: maintained1.Platform,
},
{
ID: maintained2.ID,
Name: maintained2.Name,
Version: maintained2.Version,
Platform: maintained2.Platform,
},
{
ID: maintained3.ID,
Name: maintained3.Name,
Version: maintained3.Version,
Platform: maintained3.Platform,
},
}

// Testing pagination
apps, meta, err := ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 3)
require.Equal(t, expectedApps, apps)
require.False(t, meta.HasNextResults)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{PerPage: 1, IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 1)
require.Equal(t, expectedApps[:1], apps)
require.True(t, meta.HasNextResults)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{PerPage: 1, Page: 1, IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 1)
require.Equal(t, expectedApps[1:2], apps)
require.True(t, meta.HasNextResults)
require.True(t, meta.HasPreviousResults)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{PerPage: 1, Page: 2, IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 1)
require.Equal(t, expectedApps[2:3], apps)
require.False(t, meta.HasNextResults)
require.True(t, meta.HasPreviousResults)

//
// Test excluding results for existing apps (installers)

/// Irrelevant package
_, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "Irrelevant Software",
TeamID: &team1.ID,
InstallScript: "nothing",
Filename: "foo.pkg",
UserID: user.ID,
Platform: string(fleet.MacOSPlatform),
BundleIdentifier: "irrelevant_1",
})
require.NoError(t, err)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 3)
require.Equal(t, expectedApps, apps)

/// Correct package on a different team
_, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "Maintained1",
TeamID: &team2.ID,
InstallScript: "nothing",
Filename: "foo.pkg",
UserID: user.ID,
Platform: string(fleet.MacOSPlatform),
BundleIdentifier: "fleet.maintained1",
})
require.NoError(t, err)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 3)
require.Equal(t, expectedApps, apps)

/// Correct package on the right team with the wrong platform
_, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{
Title: "Maintained1",
TeamID: &team1.ID,
InstallScript: "nothing",
Filename: "foo.pkg",
UserID: user.ID,
Platform: string(fleet.IOSPlatform),
BundleIdentifier: "fleet.maintained1",
})
require.NoError(t, err)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 3)
require.Equal(t, expectedApps, apps)

/// Correct team and platform
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE software_installers SET platform = ? WHERE platform = ?", fleet.MacOSPlatform, fleet.IOSPlatform)
return err
})

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 2)
require.Equal(t, expectedApps[1:], apps)

//
// Test excluding results for existing apps (VPP)

test.CreateInsertGlobalVPPToken(t, ds)

// irrelevant vpp app
vppIrrelevant := &fleet.VPPApp{
Name: "irrelevant_app",
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "1",
Platform: fleet.MacOSPlatform,
},
},
BundleIdentifier: "irrelevant_2",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vppIrrelevant, &team1.ID)
require.NoError(t, err)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 2)
require.Equal(t, expectedApps[1:], apps)

// right vpp app, wrong team
vppMaintained2 := &fleet.VPPApp{
Name: "Maintained 2",
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "2",
Platform: fleet.MacOSPlatform,
},
},
BundleIdentifier: "fleet.maintained2",
}
_, err = ds.InsertVPPAppWithTeam(ctx, vppMaintained2, &team2.ID)
require.NoError(t, err)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 2)
require.Equal(t, expectedApps[1:], apps)

// right vpp app, right team
_, err = ds.InsertVPPAppWithTeam(ctx, vppMaintained2, &team1.ID)
require.NoError(t, err)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 1)
require.Equal(t, expectedApps[2:], apps)

// right app, right team, wrong platform
vppMaintained3 := &fleet.VPPApp{
Name: "Maintained 3",
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "3",
Platform: fleet.IOSPlatform,
},
},
BundleIdentifier: "fleet.maintained3",
}

_, err = ds.InsertVPPAppWithTeam(ctx, vppMaintained3, &team1.ID)
require.NoError(t, err)

apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, team1.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, apps, 1)
require.Equal(t, expectedApps[2:], apps)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice tests!


func testGetMaintainedAppByID(t *testing.T, ds *Datastore) {
ctx := context.Background()

Expand Down
4 changes: 4 additions & 0 deletions server/fleet/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,10 @@ type Datastore interface {
// the given team.
UploadedSoftwareExists(ctx context.Context, bundleIdentifier string, teamID *uint) (bool, error)

///////////////////////////////////////////////////////////////////////////////
// Fleet Managed Apps
ListAvailableFleetMaintainedApps(ctx context.Context, teamID uint, opt ListOptions) ([]MaintainedApp, *PaginationMetadata, error)

///////////////////////////////////////////////////////////////////////////////
// OperatingSystemsStore

Expand Down
1 change: 1 addition & 0 deletions server/fleet/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,7 @@ type Service interface {

// AddFleetMaintainedApp adds a Fleet-maintained app to the given team.
AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript string, selfService bool) error
ListFleetMaintainedApps(ctx context.Context, teamID uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error)

// /////////////////////////////////////////////////////////////////////////////
// Maintenance windows
Expand Down
4 changes: 2 additions & 2 deletions server/mdm/maintainedapps/testing_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
// It returns the expected results of the ingestion as a slice of
// fleet.MaintainedApps with only a few fields filled - the result of
// unmarshaling the testdata/expected_apps.json file.
func IngestMaintainedApps(t *testing.T, ds fleet.Datastore) []*fleet.MaintainedApp {
func IngestMaintainedApps(t *testing.T, ds fleet.Datastore) []fleet.MaintainedApp {
_, filename, _, _ := runtime.Caller(0)
base := filepath.Dir(filename)
testdataDir := filepath.Join(base, "testdata")
Expand Down Expand Up @@ -50,7 +50,7 @@ func IngestMaintainedApps(t *testing.T, ds fleet.Datastore) []*fleet.MaintainedA
err := Refresh(context.Background(), ds, log.NewNopLogger())
require.NoError(t, err)

var expected []*fleet.MaintainedApp
var expected []fleet.MaintainedApp
b, err := os.ReadFile(filepath.Join(testdataDir, "expected_apps.json"))
require.NoError(t, err)
err = json.Unmarshal(b, &expected)
Expand Down
Loading
Loading