From aedb8bf78f0b804aa7f6454e030d272634222424 Mon Sep 17 00:00:00 2001 From: Jahziel Villasana-Espinoza Date: Tue, 15 Jul 2025 09:07:29 -0400 Subject: [PATCH] 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. - [ ] 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 --- .../ingesters/homebrew/ingester.go | 84 ++++++++++++++----- .../ingesters/homebrew/ingester_test.go | 60 ++++++++----- 2 files changed, 102 insertions(+), 42 deletions(-) diff --git a/ee/maintained-apps/ingesters/homebrew/ingester.go b/ee/maintained-apps/ingesters/homebrew/ingester.go index 3905495df4..079907535b 100644 --- a/ee/maintained-apps/ingesters/homebrew/ingester.go +++ b/ee/maintained-apps/ingesters/homebrew/ingester.go @@ -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 { diff --git a/ee/maintained-apps/ingesters/homebrew/ingester_test.go b/ee/maintained-apps/ingesters/homebrew/ingester_test.go index 3bf0a6cedb..c82f4484cc 100644 --- a/ee/maintained-apps/ingesters/homebrew/ingester_test.go +++ b/ee/maintained-apps/ingesters/homebrew/ingester_test.go @@ -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) + } + }) } }