Skip to content

Commit

Permalink
Maintained Apps: define app list, implement ingestion (#21946)
Browse files Browse the repository at this point in the history
  • Loading branch information
mna authored Sep 10, 2024
1 parent 14b6546 commit 9abd5a5
Show file tree
Hide file tree
Showing 34 changed files with 4,903 additions and 10 deletions.
1 change: 1 addition & 0 deletions changes/21773-ingest-fleet-maintained-apps
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Added the definition of the Fleet maintained apps and its ingestion.
31 changes: 31 additions & 0 deletions pkg/optjson/stringor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package optjson

import (
"bytes"
"encoding/json"
)

// StringOr is a JSON value that can be a string or a different type of object
// (e.g. somewhat common for a string or an array of strings, but can also be
// a string or an object, etc.).
type StringOr[T any] struct {
String string
Other T
IsOther bool
}

func (s StringOr[T]) MarshalJSON() ([]byte, error) {
if s.IsOther {
return json.Marshal(s.Other)
}
return json.Marshal(s.String)
}

func (s *StringOr[T]) UnmarshalJSON(data []byte) error {
if bytes.HasPrefix(data, []byte(`"`)) {
s.IsOther = false
return json.Unmarshal(data, &s.String)
}
s.IsOther = true
return json.Unmarshal(data, &s.Other)
}
137 changes: 137 additions & 0 deletions pkg/optjson/stringor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package optjson

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/require"
)

func TestStringOr(t *testing.T) {
type child struct {
Name string `json:"name"`
}

type target struct {
Field StringOr[[]string] `json:"field"`
Array []StringOr[*child] `json:"array"`
}

type nested struct {
Inception StringOr[*target] `json:"inception"`
}

cases := []struct {
name string
getVar func() any
src string // json source to unmarshal into the value returned by getVar
marshalAs string // how the value should marshal back to json
unmarshalErr string // if non-empty, unmarshal should fail with this error
}{
{
name: "simple string",
getVar: func() any { var s StringOr[int]; return &s },
src: `"abc"`,
marshalAs: `"abc"`,
},
{
name: "simple integer",
getVar: func() any { var s StringOr[int]; return &s },
src: `123`,
marshalAs: `123`,
},
{
name: "invalid bool",
getVar: func() any { var s StringOr[int]; return &s },
src: `true`,
unmarshalErr: "cannot unmarshal bool into Go value of type int",
},
{
name: "field string",
getVar: func() any { var s target; return &s },
src: `{"field":"abc"}`,
marshalAs: `{"field":"abc", "array": null}`,
},
{
name: "field strings",
getVar: func() any { var s target; return &s },
src: `{"field":["a", "b", "c"]}`,
marshalAs: `{"field":["a", "b", "c"], "array": null}`,
},
{
name: "field empty array",
getVar: func() any { var s target; return &s },
src: `{"field":[]}`,
marshalAs: `{"field":[], "array": null}`,
},
{
name: "field invalid object",
getVar: func() any { var s target; return &s },
src: `{"field":{}}`,
unmarshalErr: "cannot unmarshal object into Go struct field target.field of type []string",
},
{
name: "array field null",
getVar: func() any { var s target; return &s },
src: `{"array":null}`,
marshalAs: `{"array":null, "field": ""}`,
},
{
name: "array field empty",
getVar: func() any { var s target; return &s },
src: `{"array":[]}`,
marshalAs: `{"array":[], "field": ""}`,
},
{
name: "array field single",
getVar: func() any { var s target; return &s },
src: `{"array":["a"]}`,
marshalAs: `{"array":["a"], "field": ""}`,
},
{
name: "array field empty child",
getVar: func() any { var s target; return &s },
src: `{"array":["a", {}]}`,
marshalAs: `{"array":["a", {"name":""}], "field": ""}`,
},
{
name: "array field set child",
getVar: func() any { var s target; return &s },
src: `{"array":["a", {"name": "x"}]}`,
marshalAs: `{"array":["a", {"name":"x"}], "field": ""}`,
},
{
name: "inception string",
getVar: func() any { var s nested; return &s },
src: `{"inception":"a"}`,
marshalAs: `{"inception":"a"}`,
},
{
name: "inception target field",
getVar: func() any { var s nested; return &s },
src: `{"inception":{"field":["a", "b"]}}`,
marshalAs: `{"inception":{"field":["a", "b"], "array": null}}`,
},
{
name: "inception target field and array",
getVar: func() any { var s nested; return &s },
src: `{"inception":{"field":["a", "b"], "array": ["c", {"name": "x"}]}}`,
marshalAs: `{"inception":{"field":["a", "b"], "array": ["c", {"name": "x"}]}}`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
target := c.getVar()
err := json.Unmarshal([]byte(c.src), target)
if c.unmarshalErr != "" {
require.ErrorContains(t, err, c.unmarshalErr)
return
}
require.NoError(t, err)

data, err := json.Marshal(target)
require.NoError(t, err)
require.JSONEq(t, c.marshalAs, string(data))
})
}
}
55 changes: 55 additions & 0 deletions server/datastore/mysql/maintained_apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package mysql

import (
"context"

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

func (ds *Datastore) UpsertMaintainedApp(ctx context.Context, app *fleet.MaintainedApp) error {
const upsertStmt = `
INSERT INTO
fleet_library_apps (
name, token, version, platform, installer_url, filename,
sha256, bundle_identifier, install_script_content_id, uninstall_script_content_id
)
VALUES
( ?, ?, ?, ?, ?, ?,
?, ?, ?, ? )
ON DUPLICATE KEY UPDATE
name = VALUES(name),
version = VALUES(version),
platform = VALUES(platform),
installer_url = VALUES(installer_url),
filename = VALUES(filename),
sha256 = VALUES(sha256),
bundle_identifier = VALUES(bundle_identifier),
install_script_content_id = VALUES(install_script_content_id),
uninstall_script_content_id = VALUES(uninstall_script_content_id)
`

return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
var err error

// ensure the install script exists
installRes, err := insertScriptContents(ctx, tx, app.InstallScript)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert install script content")
}
installScriptID, _ := installRes.LastInsertId()

// ensure the uninstall script exists
uninstallRes, err := insertScriptContents(ctx, tx, app.UninstallScript)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert uninstall script content")
}
uninstallScriptID, _ := uninstallRes.LastInsertId()

// upsert the maintained app
_, err = tx.ExecContext(ctx, upsertStmt, app.Name, app.Token, app.Version, app.Platform, app.InstallerURL, app.Filename,
app.SHA256, app.BundleIdentifier, installScriptID, uninstallScriptID)
return ctxerr.Wrap(ctx, err, "upsert maintained app")
})
}
87 changes: 87 additions & 0 deletions server/datastore/mysql/maintained_apps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package mysql

import (
"context"
"os"
"testing"

"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
"github.com/go-kit/kit/log"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)

func TestMaintainedApps(t *testing.T) {
ds := CreateMySQLDS(t)

cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"UpsertMaintainedApps", testUpsertMaintainedApps},
{"IngestWithBrew", testIngestWithBrew},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}

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

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")
})
return apps
}

expectedApps := maintainedapps.IngestMaintainedApps(t, ds)
require.Equal(t, expectedApps, listSavedApps())

// ingesting again results in no changes
maintainedapps.IngestMaintainedApps(t, ds)
require.Equal(t, expectedApps, listSavedApps())

// upsert the figma app, changing the version
err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{
Name: "Figma",
Token: "figma",
InstallerURL: "https://desktop.figma.com/mac-arm/Figma-999.9.9.zip",
Version: "999.9.9",
Platform: fleet.MacOSPlatform,
})
require.NoError(t, err)

// change the expected app data for figma
for _, app := range expectedApps {
if app.Name == "Figma" {
app.Version = "999.9.9"
break
}
}
require.Equal(t, expectedApps, listSavedApps())
}

func testIngestWithBrew(t *testing.T, ds *Datastore) {
if os.Getenv("NETWORK_TEST") == "" {
t.Skip("set environment variable NETWORK_TEST=1 to run")
}

ctx := context.Background()
err := maintainedapps.Refresh(ctx, ds, log.NewNopLogger())
require.NoError(t, err)

expectedTokens := maintainedapps.ExpectedAppTokens(t)
var actualTokens []string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(ctx, q, &actualTokens, "SELECT token FROM fleet_library_apps ORDER BY token")
})
require.ElementsMatch(t, expectedTokens, actualTokens)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,24 @@ func init() {
func Up_20240909145426(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE TABLE fleet_library_apps (
id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
id int unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT,
name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
-- the "full_token" field from homebrew's JSON API response
-- see e.g. https://formulae.brew.sh/api/cask/1password.json
token varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
version varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
platform varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
installer_url varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
filename varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
token varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
version varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
platform varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
installer_url varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
filename varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
-- hash of the binary downloaded from installer_url, allows us to validate we got the right bytes
-- before sending to S3 (and we store installers on S3 under that sha256 hash as identifier).
sha256 varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
sha256 varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
-- bundle_identifier is used to match the library app with a software title in the software_titles table,
-- it is expected to be provided by the hard-coded JSON list of apps in the Fleet library.
bundle_identifier varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
created_at timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
created_at timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
-- foreign-key ids of the script_contents table.
install_script_content_id int unsigned NOT NULL,
Expand Down
1 change: 1 addition & 0 deletions server/datastore/mysql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ CREATE TABLE `fleet_library_apps` (
`installer_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`filename` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`sha256` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`bundle_identifier` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`created_at` timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6),
`updated_at` timestamp(6) NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
`install_script_content_id` int unsigned NOT NULL,
Expand Down
4 changes: 4 additions & 0 deletions server/fleet/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -1685,6 +1685,10 @@ type Datastore interface {
GetPastActivityDataForVPPAppInstall(ctx context.Context, commandResults *mdm.CommandResults) (*User, *ActivityInstalledAppStoreApp, error)

GetVPPTokenByLocation(ctx context.Context, loc string) (*VPPTokenDB, error)

// UpsertMaintainedApp inserts or updates a maintained app using the updated
// metadata provided via app.
UpsertMaintainedApp(ctx context.Context, app *MaintainedApp) error
}

// MDMAppleStore wraps nanomdm's storage and adds methods to deal with
Expand Down
21 changes: 21 additions & 0 deletions server/fleet/maintained_apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package fleet

// MaintainedApp represets an app in the Fleet library of maintained apps,
// as stored in the fleet_library_apps table.
type MaintainedApp struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Token string `json:"-" db:"name"`
Version string `json:"version" db:"version"`
Platform AppleDevicePlatform `json:"platform" db:"platform"`
InstallerURL string `json:"-" db:"installer_url"`
Filename string `json:"filename" db:"filename"`
SHA256 string `json:"-" db:"sha256"`
BundleIdentifier string `json:"-" db:"bundle_identifier"`

// InstallScript and UninstallScript are not stored directly in the table, they
// must be filled via a JOIN on script_contents. On insert/update/upsert, these
// fields are used to provide the content of those scripts.
InstallScript string `json:"install_script" db:"install_script"`
UninstallScript string `json:"uninstall_script" db:"uninstall_script"`
}
Loading

0 comments on commit 9abd5a5

Please sign in to comment.