allow manually specified install and uninstall scripts for homebrew (#30805)

> Closes #30780

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [ ] 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.
- [x] Added/updated automated tests
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Jahziel Villasana-Espinoza 2025-07-15 09:07:29 -04:00 committed by GitHub
parent db700abb54
commit aedb8bf78f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 102 additions and 42 deletions

View file

@ -37,6 +37,10 @@ func IngestApps(ctx context.Context, logger kitlog.Logger, inputsPath, slugFilte
var manifestApps []*maintained_apps.FMAManifestApp
for _, f := range files {
if f.IsDir() {
continue
}
fileBytes, err := os.ReadFile(path.Join(inputsPath, f.Name()))
if err != nil {
return nil, ctxerr.WrapWithData(ctx, err, "reading app input file", map[string]any{"fileName": f.Name()})
@ -85,8 +89,8 @@ type brewIngester struct {
client *http.Client
}
func (i *brewIngester) ingestOne(ctx context.Context, app inputApp) (*maintained_apps.FMAManifestApp, error) {
apiURL := fmt.Sprintf("%scask/%s.json", i.baseURL, app.Token)
func (i *brewIngester) ingestOne(ctx context.Context, input inputApp) (*maintained_apps.FMAManifestApp, error) {
apiURL := fmt.Sprintf("%scask/%s.json", i.baseURL, input.Token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
@ -118,55 +122,89 @@ func (i *brewIngester) ingestOne(ctx context.Context, app inputApp) (*maintained
var cask brewCask
if err := json.Unmarshal(body, &cask); err != nil {
return nil, ctxerr.Wrapf(ctx, err, "unmarshal brew cask for %s", app.Token)
return nil, ctxerr.Wrapf(ctx, err, "unmarshal brew cask for %s", input.Token)
}
out := &maintained_apps.FMAManifestApp{}
// validate required fields
if len(cask.Name) == 0 || cask.Name[0] == "" {
return nil, ctxerr.Errorf(ctx, "missing name for cask %s", app.Token)
return nil, ctxerr.Errorf(ctx, "missing name for cask %s", input.Token)
}
if cask.Token == "" {
return nil, ctxerr.Errorf(ctx, "missing token for cask %s", app.Token)
return nil, ctxerr.Errorf(ctx, "missing token for cask %s", input.Token)
}
if cask.Version == "" {
return nil, ctxerr.Errorf(ctx, "missing version for cask %s", app.Token)
return nil, ctxerr.Errorf(ctx, "missing version for cask %s", input.Token)
}
if cask.URL == "" {
return nil, ctxerr.Errorf(ctx, "missing URL for cask %s", app.Token)
return nil, ctxerr.Errorf(ctx, "missing URL for cask %s", input.Token)
}
_, err = url.Parse(cask.URL)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "parse URL for cask %s", app.Token)
return nil, ctxerr.Wrapf(ctx, err, "parse URL for cask %s", input.Token)
}
out.Name = app.Name
out.Name = input.Name
out.Version = strings.Split(cask.Version, ",")[0]
out.InstallerURL = cask.URL
out.UniqueIdentifier = app.UniqueIdentifier
out.UniqueIdentifier = input.UniqueIdentifier
out.SHA256 = cask.SHA256
out.Queries = maintained_apps.FMAQueries{Exists: fmt.Sprintf("SELECT 1 FROM apps WHERE bundle_identifier = '%s';", out.UniqueIdentifier)}
out.Slug = app.Slug
out.DefaultCategories = app.DefaultCategories
if len(app.PreUninstallScripts) != 0 {
cask.PreUninstallScripts = app.PreUninstallScripts
out.Slug = input.Slug
out.DefaultCategories = input.DefaultCategories
var installScript, uninstallScript string
switch input.InstallScriptPath {
case "":
installScript, err = installScriptForApp(input, &cask)
if err != nil {
return nil, ctxerr.WrapWithData(ctx, err, "generating install script for maintained app", map[string]any{"unique_identifier": input.UniqueIdentifier})
}
default:
scriptBytes, err := os.ReadFile(input.InstallScriptPath)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "reading provided install script file")
}
installScript = string(scriptBytes)
}
if len(app.PostUninstallScripts) != 0 {
cask.PostUninstallScripts = app.PostUninstallScripts
switch input.UninstallScriptPath {
case "":
if len(input.PreUninstallScripts) != 0 {
cask.PreUninstallScripts = input.PreUninstallScripts
}
if len(input.PostUninstallScripts) != 0 {
cask.PostUninstallScripts = input.PostUninstallScripts
}
uninstallScript = uninstallScriptForApp(&cask)
default:
if len(input.PreUninstallScripts) != 0 {
return nil, ctxerr.New(ctx, "cannot provide pre-uninstall scripts if uninstall script is provided")
}
if len(input.PostUninstallScripts) != 0 {
return nil, ctxerr.New(ctx, "cannot provide post-uninstall scripts if uninstall script is provided")
}
scriptBytes, err := os.ReadFile(input.UninstallScriptPath)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "reading provided uninstall script file")
}
uninstallScript = string(scriptBytes)
}
out.UninstallScript = uninstallScriptForApp(&cask)
installScript, err := installScriptForApp(app, &cask)
if err != nil {
return nil, ctxerr.WrapWithData(ctx, err, "generating install script for maintained app", map[string]any{"unique_identifier": app.UniqueIdentifier})
}
out.InstallScript = installScript
out.UninstallScript = uninstallScript
out.UninstallScriptRef = maintained_apps.GetScriptRef(out.UninstallScript)
out.InstallScriptRef = maintained_apps.GetScriptRef(out.InstallScript)
out.Frozen = app.Frozen
out.Frozen = input.Frozen
return out, nil
}
@ -186,6 +224,8 @@ type inputApp struct {
PostUninstallScripts []string `json:"post_uninstall_scripts"`
DefaultCategories []string `json:"default_categories"`
Frozen bool `json:"frozen"`
InstallScriptPath string `json:"install_script_path"`
UninstallScriptPath string `json:"uninstall_script_path"`
}
type brewCask struct {

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path"
"strings"
"testing"
@ -16,6 +17,14 @@ import (
)
func TestIngestValidations(t *testing.T) {
tempDir := t.TempDir()
testInstallScriptContents := "this is a test install script"
require.NoError(t, os.WriteFile(path.Join(tempDir, "install_script.sh"), []byte(testInstallScriptContents), 0644))
testUninstallScriptContents := "this is a test uninstall script"
require.NoError(t, os.WriteFile(path.Join(tempDir, "uninstall_script.sh"), []byte(testUninstallScriptContents), 0644))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var cask brewCask
@ -77,7 +86,7 @@ func TestIngestValidations(t *testing.T) {
Version: "1.0",
}
case "ok":
case "ok", "install_script_path", "uninstall_script_path", "uninstall_script_path_with_pre", "uninstall_script_path_with_post":
cask = brewCask{
Token: appToken,
Name: []string{appToken},
@ -98,36 +107,47 @@ func TestIngestValidations(t *testing.T) {
ctx := context.Background()
cases := []struct {
appToken string
wantErr string
upsertCalled bool
wantErr string
inputApp inputApp
}{
{"fail", "brew API returned status 500", false},
{"notfound", "app not found in brew API", false},
{"noname", "missing name for cask noname", false},
{"emptyname", "missing name for cask emptyname", false},
{"notoken", "missing token for cask notoken", false},
{"noversion", "missing version for cask noversion", false},
{"nourl", "missing URL for cask nourl", false},
{"invalidurl", "parse URL for cask invalidurl", false},
{"ok", "", true},
{"brew API returned status 500", inputApp{Token: "fail", UniqueIdentifier: "abc", InstallerFormat: "pkg"}},
{"app not found in brew API", inputApp{Token: "notfound", UniqueIdentifier: "abc", InstallerFormat: "pkg"}},
{"missing name for cask noname", inputApp{Token: "noname", UniqueIdentifier: "abc", InstallerFormat: "pkg"}},
{"missing name for cask emptyname", inputApp{Token: "emptyname", UniqueIdentifier: "abc", InstallerFormat: "pkg"}},
{"missing token for cask notoken", inputApp{Token: "notoken", UniqueIdentifier: "abc", InstallerFormat: "pkg"}},
{"missing version for cask noversion", inputApp{Token: "noversion", UniqueIdentifier: "abc", InstallerFormat: "pkg"}},
{"missing URL for cask nourl", inputApp{Token: "nourl", UniqueIdentifier: "abc", InstallerFormat: "pkg"}},
{"parse URL for cask invalidurl", inputApp{Token: "invalidurl", UniqueIdentifier: "abc", InstallerFormat: "pkg"}},
{"", inputApp{Token: "ok", UniqueIdentifier: "abc", InstallerFormat: "pkg"}},
{"", inputApp{Token: "install_script_path", UniqueIdentifier: "abc", InstallerFormat: "pkg", InstallScriptPath: path.Join(tempDir, "install_script.sh")}},
{"", inputApp{Token: "uninstall_script_path", UniqueIdentifier: "abc", InstallerFormat: "pkg", UninstallScriptPath: path.Join(tempDir, "uninstall_script.sh")}},
{"cannot provide pre-uninstall scripts if uninstall script is provided", inputApp{Token: "uninstall_script_path_with_pre", UniqueIdentifier: "abc", InstallerFormat: "pkg", UninstallScriptPath: path.Join(tempDir, "uninstall_script.sh"), PreUninstallScripts: []string{"foo", "bar"}}},
{"cannot provide post-uninstall scripts if uninstall script is provided", inputApp{Token: "uninstall_script_path_with_post", UniqueIdentifier: "abc", InstallerFormat: "pkg", UninstallScriptPath: path.Join(tempDir, "uninstall_script.sh"), PostUninstallScripts: []string{"foo", "bar"}}},
}
for _, c := range cases {
t.Run(c.appToken, func(t *testing.T) {
t.Run(c.inputApp.Token, func(t *testing.T) {
i := &brewIngester{
logger: log.NewNopLogger(),
client: fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)),
baseURL: srv.URL + "/",
}
inputApp := inputApp{Token: c.appToken, UniqueIdentifier: "abc", InstallerFormat: "pkg"}
_, err := i.ingestOne(ctx, inputApp)
if c.wantErr == "" {
require.NoError(t, err)
} else {
out, err := i.ingestOne(ctx, c.inputApp)
if c.wantErr != "" {
require.ErrorContains(t, err, c.wantErr)
return
}
require.NoError(t, err)
if c.inputApp.InstallScriptPath != "" {
require.Equal(t, testInstallScriptContents, out.InstallScript)
}
if c.inputApp.UninstallScriptPath != "" {
require.Equal(t, testUninstallScriptContents, out.UninstallScript)
}
})
}
}