From da9bac09eba2986dedfa6b3d18bafbe5f339d83c Mon Sep 17 00:00:00 2001 From: Ian Littman Date: Wed, 13 Aug 2025 07:48:36 -0500 Subject: [PATCH] Add support for install/uninstall script overrides, pre-install query, post-install script in FMA GitOps (#31803) Also removed the automatic install flag on YAML FMAs as it's undocumented/unspec'd Fixes #25636. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] 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] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually --- changes/25636-fma-gitops | 1 + ee/server/service/software_installers.go | 11 ++-- pkg/spec/gitops.go | 51 ++++++++------- pkg/spec/gitops_test.go | 20 +++++- pkg/spec/testdata/lib/install.sh | 1 + pkg/spec/testdata/lib/post-install.sh | 1 + pkg/spec/testdata/lib/preinstall-query.yml | 1 + pkg/spec/testdata/lib/uninstall-box.sh | 1 + pkg/spec/testdata/team_config.yml | 8 +++ pkg/spec/testdata/team_config_no_paths.yml | 8 +++ pkg/spec/testdata/team_config_only_sha256.yml | 8 +++ server/fleet/scripts.go | 5 +- server/fleet/software_installer.go | 65 +++++++++++++++---- server/service/client.go | 18 +---- server/service/integration_enterprise_test.go | 9 ++- 15 files changed, 146 insertions(+), 62 deletions(-) create mode 100644 changes/25636-fma-gitops create mode 100644 pkg/spec/testdata/lib/install.sh create mode 100644 pkg/spec/testdata/lib/post-install.sh create mode 100644 pkg/spec/testdata/lib/preinstall-query.yml create mode 100644 pkg/spec/testdata/lib/uninstall-box.sh diff --git a/changes/25636-fma-gitops b/changes/25636-fma-gitops new file mode 100644 index 0000000000..ef43109bb3 --- /dev/null +++ b/changes/25636-fma-gitops @@ -0,0 +1 @@ +* Allowed overriding install/uninstall scripts, and specifying pre-install queries and post-install scripts, for Fleet-maintained apps in GitOps diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 5128774fde..1919c6bdf1 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -1719,8 +1719,12 @@ func (svc *Service) softwareInstallerPayloadFromSlug(ctx context.Context, payloa if app.SHA256 != noCheckHash { payload.SHA256 = app.SHA256 } - payload.InstallScript = app.InstallScript - payload.UninstallScript = app.UninstallScript + if payload.InstallScript == "" { + payload.InstallScript = app.InstallScript + } + if payload.UninstallScript == "" { + payload.UninstallScript = app.UninstallScript + } payload.FleetMaintained = true payload.MaintainedApp = app if len(payload.Categories) == 0 { @@ -2004,7 +2008,6 @@ func (svc *Service) softwareBatchUpload( } } extension := strings.TrimLeft(filepath.Ext(installer.Filename), ".") - installer.AutomaticInstallQuery = p.MaintainedApp.AutomaticInstallQuery installer.Title = appName installer.Version = p.MaintainedApp.Version @@ -2030,8 +2033,6 @@ func (svc *Service) softwareBatchUpload( installer.BundleIdentifier = p.MaintainedApp.BundleIdentifier() installer.StorageID = p.MaintainedApp.SHA256 installer.FleetMaintainedAppID = &p.MaintainedApp.ID - installer.AutomaticInstall = p.AutomaticInstall != nil && *p.AutomaticInstall - installer.AutomaticInstallQuery = p.MaintainedApp.AutomaticInstallQuery } var ext string diff --git a/pkg/spec/gitops.go b/pkg/spec/gitops.go index 7a7d5c3f76..11f0b37075 100644 --- a/pkg/spec/gitops.go +++ b/pkg/spec/gitops.go @@ -162,9 +162,9 @@ type SoftwarePackage struct { } type Software struct { - Packages []SoftwarePackage `json:"packages"` - AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"` - FleetMaintainedApps []fleet.FleetMaintainedAppsSpec `json:"fleet_maintained_apps"` + Packages []SoftwarePackage `json:"packages"` + AppStoreApps []fleet.TeamSpecAppStoreApp `json:"app_store_apps"` + FleetMaintainedApps []fleet.MaintainedAppSpec `json:"fleet_maintained_apps"` } type GitOps struct { @@ -186,7 +186,7 @@ type GitOps struct { type GitOpsSoftware struct { Packages []*fleet.SoftwarePackageSpec AppStoreApps []*fleet.TeamSpecAppStoreApp - FleetMaintainedApps []*fleet.FleetMaintainedAppsSpec + FleetMaintainedApps []*fleet.MaintainedAppSpec } type Logf func(format string, a ...interface{}) @@ -1095,6 +1095,28 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin continue } + item = item.ResolveSoftwarePackagePaths(baseDir) + + // handle secrets + if item.InstallScript.Path != "" { + if err := gatherFileSecrets(result, item.InstallScript.Path); err != nil { + multiError = multierror.Append(multiError, err) + continue + } + } + if item.PostInstallScript.Path != "" { + if err := gatherFileSecrets(result, item.PostInstallScript.Path); err != nil { + multiError = multierror.Append(multiError, err) + continue + } + } + if item.UninstallScript.Path != "" { + if err := gatherFileSecrets(result, item.UninstallScript.Path); err != nil { + multiError = multierror.Append(multiError, err) + continue + } + } + result.Software.FleetMaintainedApps = append(result.Software.FleetMaintainedApps, &item) } for _, item := range software.Packages { @@ -1117,9 +1139,9 @@ func parseSoftware(top map[string]json.RawMessage, result *GitOps, baseDir strin continue } - softwarePackageSpec = resolveSoftwarePackagePaths(filepath.Dir(softwarePackageSpec.ReferencedYamlPath), softwarePackageSpec) + softwarePackageSpec = softwarePackageSpec.ResolveSoftwarePackagePaths(filepath.Dir(softwarePackageSpec.ReferencedYamlPath)) } else { - softwarePackageSpec = resolveSoftwarePackagePaths(baseDir, item.SoftwarePackageSpec) + softwarePackageSpec = item.SoftwarePackageSpec.ResolveSoftwarePackagePaths(baseDir) } if softwarePackageSpec.InstallScript.Path != "" { if err := gatherFileSecrets(result, softwarePackageSpec.InstallScript.Path); err != nil { @@ -1194,23 +1216,6 @@ func gatherFileSecrets(result *GitOps, filePath string) error { return nil } -func resolveSoftwarePackagePaths(baseDir string, softwareSpec fleet.SoftwarePackageSpec) fleet.SoftwarePackageSpec { - if softwareSpec.PreInstallQuery.Path != "" { - softwareSpec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, softwareSpec.PreInstallQuery.Path) - } - if softwareSpec.InstallScript.Path != "" { - softwareSpec.InstallScript.Path = resolveApplyRelativePath(baseDir, softwareSpec.InstallScript.Path) - } - if softwareSpec.PostInstallScript.Path != "" { - softwareSpec.PostInstallScript.Path = resolveApplyRelativePath(baseDir, softwareSpec.PostInstallScript.Path) - } - if softwareSpec.UninstallScript.Path != "" { - softwareSpec.UninstallScript.Path = resolveApplyRelativePath(baseDir, softwareSpec.UninstallScript.Path) - } - - return softwareSpec -} - func getDuplicateNames[T any](slice []T, getComparableString func(T) string) []string { // We are using the allKeys map as a set here. True means the item is a duplicate. allKeys := make(map[string]bool) diff --git a/pkg/spec/gitops_test.go b/pkg/spec/gitops_test.go index a94319cd7f..f275d1fc9d 100644 --- a/pkg/spec/gitops_test.go +++ b/pkg/spec/gitops_test.go @@ -105,6 +105,9 @@ func TestValidGitOpsYaml(t *testing.T) { "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_length": "10", "FLEET_SECRET_BANANA": "bread", + "FLEET_SECRET_CLEMENTINE": "not-an-orange", + "FLEET_SECRET_DURIAN": "fruity", // not used + "FLEET_SECRET_EGGPLANT": "parmesan", }, filePath: "testdata/team_config_no_paths.yml", isTeam: true, @@ -119,6 +122,9 @@ func TestValidGitOpsYaml(t *testing.T) { "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_length": "10", "FLEET_SECRET_BANANA": "bread", + "FLEET_SECRET_CLEMENTINE": "not-an-orange", + "FLEET_SECRET_DURIAN": "fruity", // not used + "FLEET_SECRET_EGGPLANT": "parmesan", }, filePath: "testdata/team_config.yml", isTeam: true, @@ -133,6 +139,9 @@ func TestValidGitOpsYaml(t *testing.T) { "FLEET_SECRET_NAME": "secret_name", "FLEET_SECRET_length": "10", "FLEET_SECRET_BANANA": "bread", + "FLEET_SECRET_CLEMENTINE": "not-an-orange", + "FLEET_SECRET_DURIAN": "fruity", // not used + "FLEET_SECRET_EGGPLANT": "parmesan", }, filePath: "testdata/team_config_only_sha256.yml", isTeam: true, @@ -188,6 +197,7 @@ func TestValidGitOpsYaml(t *testing.T) { assert.Equal(t, "SampleSecret123", secrets.([]*fleet.EnrollSecret)[0].Secret) assert.Equal(t, "ABC", secrets.([]*fleet.EnrollSecret)[1].Secret) require.Len(t, gitops.Software.Packages, 2) + require.Len(t, gitops.FleetSecrets, 6) for _, pkg := range gitops.Software.Packages { if strings.Contains(pkg.URL, "MicrosoftTeams") { assert.Equal(t, "testdata/lib/uninstall.sh", pkg.UninstallScript.Path) @@ -200,8 +210,16 @@ func TestValidGitOpsYaml(t *testing.T) { switch fma.Slug { case "slack/darwin": require.ElementsMatch(t, fma.Categories, []string{"Productivity", "Communication"}) + require.Empty(t, fma.PreInstallQuery) + require.Empty(t, fma.PostInstallScript) + require.Empty(t, fma.InstallScript) + require.Empty(t, fma.UninstallScript) case "box-drive/windows": require.ElementsMatch(t, fma.Categories, []string{"Productivity", "Developer tools"}) + require.NotEmpty(t, fma.PreInstallQuery) + require.NotEmpty(t, fma.PostInstallScript) + require.NotEmpty(t, fma.InstallScript) + require.NotEmpty(t, fma.UninstallScript) default: assert.FailNow(t, "unexpected slug found in gitops file", "slug: %s", fma.Slug) } @@ -240,6 +258,7 @@ func TestValidGitOpsYaml(t *testing.T) { activityExpiryWindow, ok := activityExpirySettings["activity_expiry_window"].(float64) require.True(t, ok) require.Equal(t, 30, int(activityExpiryWindow)) + require.Len(t, gitops.FleetSecrets, 4) // Check labels require.Len(t, gitops.Labels, 2) @@ -274,7 +293,6 @@ func TestValidGitOpsYaml(t *testing.T) { assert.True(t, ok, "windows_migration_enabled not found") _, ok = gitops.Controls.WindowsUpdates.(map[string]interface{}) assert.True(t, ok, "windows_updates not found") - require.Len(t, gitops.FleetSecrets, 4) assert.Equal(t, "fleet_secret", gitops.FleetSecrets["FLEET_SECRET_FLEET_SECRET_"]) assert.Equal(t, "secret_name", gitops.FleetSecrets["FLEET_SECRET_NAME"]) assert.Equal(t, "10", gitops.FleetSecrets["FLEET_SECRET_length"]) diff --git a/pkg/spec/testdata/lib/install.sh b/pkg/spec/testdata/lib/install.sh new file mode 100644 index 0000000000..10758ea80c --- /dev/null +++ b/pkg/spec/testdata/lib/install.sh @@ -0,0 +1 @@ +echo $FLEET_SECRET_EGGPLANT \ No newline at end of file diff --git a/pkg/spec/testdata/lib/post-install.sh b/pkg/spec/testdata/lib/post-install.sh new file mode 100644 index 0000000000..10758ea80c --- /dev/null +++ b/pkg/spec/testdata/lib/post-install.sh @@ -0,0 +1 @@ +echo $FLEET_SECRET_EGGPLANT \ No newline at end of file diff --git a/pkg/spec/testdata/lib/preinstall-query.yml b/pkg/spec/testdata/lib/preinstall-query.yml new file mode 100644 index 0000000000..b2cd579d5a --- /dev/null +++ b/pkg/spec/testdata/lib/preinstall-query.yml @@ -0,0 +1 @@ +- query: SELECT 1 FROM osquery_info \ No newline at end of file diff --git a/pkg/spec/testdata/lib/uninstall-box.sh b/pkg/spec/testdata/lib/uninstall-box.sh new file mode 100644 index 0000000000..492acf5112 --- /dev/null +++ b/pkg/spec/testdata/lib/uninstall-box.sh @@ -0,0 +1 @@ +echo $FLEET_SECRET_CLEMENTINE \ No newline at end of file diff --git a/pkg/spec/testdata/team_config.yml b/pkg/spec/testdata/team_config.yml index 4d87a62d9b..b34aa7ff4e 100644 --- a/pkg/spec/testdata/team_config.yml +++ b/pkg/spec/testdata/team_config.yml @@ -52,6 +52,14 @@ software: - Productivity - Communication - slug: box-drive/windows + install_script: + path: ./lib/install.sh + uninstall_script: + path: ./lib/uninstall-box.sh + post_install_script: + path: ./lib/post-install.sh + pre_install_query: + path: ./lib/preinstall-query.yml self_service: true categories: - Productivity diff --git a/pkg/spec/testdata/team_config_no_paths.yml b/pkg/spec/testdata/team_config_no_paths.yml index 71ffe21639..f3d839a5a2 100644 --- a/pkg/spec/testdata/team_config_no_paths.yml +++ b/pkg/spec/testdata/team_config_no_paths.yml @@ -154,6 +154,14 @@ software: - Productivity - Communication - slug: box-drive/windows + install_script: + path: ./lib/install.sh + uninstall_script: + path: ./lib/uninstall-box.sh + post_install_script: + path: ./lib/post-install.sh + pre_install_query: + path: ./lib/preinstall-query.yml self_service: true categories: - Productivity diff --git a/pkg/spec/testdata/team_config_only_sha256.yml b/pkg/spec/testdata/team_config_only_sha256.yml index f5314214c1..324f2b8737 100644 --- a/pkg/spec/testdata/team_config_only_sha256.yml +++ b/pkg/spec/testdata/team_config_only_sha256.yml @@ -56,3 +56,11 @@ software: categories: - Productivity - Developer tools + install_script: + path: ./lib/install.sh + uninstall_script: + path: ./lib/uninstall-box.sh + post_install_script: + path: ./lib/post-install.sh + pre_install_query: + path: ./lib/preinstall-query.yml diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 4a4ae53ccc..3b4412cfdd 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -415,9 +415,8 @@ type SoftwareInstallerPayload struct { SHA256 string `json:"sha256"` Categories []string `json:"categories"` // This is to support FMAs - Slug *string `json:"slug"` - AutomaticInstall *bool `json:"automatic_install"` - MaintainedApp *MaintainedApp `json:"-"` + Slug *string `json:"slug"` + MaintainedApp *MaintainedApp `json:"-"` } type HostLockWipeStatus struct { diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 04469943c1..7f2308428e 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -556,8 +556,7 @@ type SoftwarePackageSpec struct { LabelsExcludeAny []string `json:"labels_exclude_any"` // FMA - Slug *string `json:"slug"` - AutomaticInstall *bool `json:"automatic_install"` + Slug *string `json:"slug"` // ReferencedYamlPath is the resolved path of the file used to fill the // software package. Only present after parsing a GitOps file on the fleetctl @@ -572,19 +571,61 @@ type SoftwarePackageSpec struct { Categories []string `json:"categories"` } -type FleetMaintainedAppsSpec struct { - Slug string `json:"slug"` - AutomaticInstall *bool `json:"automatic_install"` - SelfService bool `json:"self_service"` - LabelsIncludeAny []string `json:"labels_include_any"` - LabelsExcludeAny []string `json:"labels_exclude_any"` - Categories []string `json:"categories"` +func (spec SoftwarePackageSpec) ResolveSoftwarePackagePaths(baseDir string) SoftwarePackageSpec { + spec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, spec.PreInstallQuery.Path) + spec.InstallScript.Path = resolveApplyRelativePath(baseDir, spec.InstallScript.Path) + spec.PostInstallScript.Path = resolveApplyRelativePath(baseDir, spec.PostInstallScript.Path) + spec.UninstallScript.Path = resolveApplyRelativePath(baseDir, spec.UninstallScript.Path) + + return spec +} + +func resolveApplyRelativePath(baseDir string, path string) string { + if path != "" && baseDir != "" && !filepath.IsAbs(path) { + return filepath.Join(baseDir, path) + } + + return path +} + +type MaintainedAppSpec struct { + Slug string `json:"slug"` + SelfService bool `json:"self_service"` + PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` + InstallScript TeamSpecSoftwareAsset `json:"install_script"` + PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` + UninstallScript TeamSpecSoftwareAsset `json:"uninstall_script"` + LabelsIncludeAny []string `json:"labels_include_any"` + LabelsExcludeAny []string `json:"labels_exclude_any"` + Categories []string `json:"categories"` +} + +func (spec MaintainedAppSpec) ToSoftwarePackageSpec() SoftwarePackageSpec { + return SoftwarePackageSpec{ + Slug: &spec.Slug, + PreInstallQuery: spec.PreInstallQuery, + InstallScript: spec.InstallScript, + PostInstallScript: spec.PostInstallScript, + UninstallScript: spec.UninstallScript, + SelfService: spec.SelfService, + LabelsIncludeAny: spec.LabelsIncludeAny, + LabelsExcludeAny: spec.LabelsExcludeAny, + } +} + +func (spec MaintainedAppSpec) ResolveSoftwarePackagePaths(baseDir string) MaintainedAppSpec { + spec.PreInstallQuery.Path = resolveApplyRelativePath(baseDir, spec.PreInstallQuery.Path) + spec.InstallScript.Path = resolveApplyRelativePath(baseDir, spec.InstallScript.Path) + spec.PostInstallScript.Path = resolveApplyRelativePath(baseDir, spec.PostInstallScript.Path) + spec.UninstallScript.Path = resolveApplyRelativePath(baseDir, spec.UninstallScript.Path) + + return spec } type SoftwareSpec struct { - Packages optjson.Slice[SoftwarePackageSpec] `json:"packages,omitempty"` - FleetMaintainedApps optjson.Slice[FleetMaintainedAppsSpec] `json:"fleet_maintained_apps,omitempty"` - AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"` + Packages optjson.Slice[SoftwarePackageSpec] `json:"packages,omitempty"` + FleetMaintainedApps optjson.Slice[MaintainedAppSpec] `json:"fleet_maintained_apps,omitempty"` + AppStoreApps optjson.Slice[TeamSpecAppStoreApp] `json:"app_store_apps,omitempty"` } // HostSoftwareInstall represents installation of software on a host from a diff --git a/server/service/client.go b/server/service/client.go index dbe4c25bed..59d018ccc0 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -1111,7 +1111,6 @@ func buildSoftwarePackagesPayload(specs []fleet.SoftwarePackageSpec, installDuri if si.Slug != nil { softwarePayloads[i].Slug = si.Slug - softwarePayloads[i].AutomaticInstall = si.AutomaticInstall } } @@ -1473,14 +1472,7 @@ func extractTmSpecsFleetMaintainedApps(tmSpecs []json.RawMessage) map[string][]f packages = []fleet.SoftwarePackageSpec{} } else { for _, app := range software.FleetMaintainedApps.Value { - packages = append(packages, fleet.SoftwarePackageSpec{ - Slug: &app.Slug, - AutomaticInstall: app.AutomaticInstall, - SelfService: app.SelfService, - LabelsIncludeAny: app.LabelsIncludeAny, - LabelsExcludeAny: app.LabelsExcludeAny, - Categories: app.Categories, - }) + packages = append(packages, app.ToSoftwarePackageSpec()) } } m[spec.Name] = packages @@ -2150,13 +2142,7 @@ func (c *Client) doGitOpsNoTeamSetupAndSoftware( } for _, software := range config.Software.FleetMaintainedApps { if software != nil { - packages = append(packages, fleet.SoftwarePackageSpec{ - Slug: &software.Slug, - AutomaticInstall: software.AutomaticInstall, - SelfService: software.SelfService, - LabelsIncludeAny: software.LabelsIncludeAny, - LabelsExcludeAny: software.LabelsExcludeAny, - }) + packages = append(packages, software.ToSoftwarePackageSpec()) } } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index d79481a0ae..18b02999d2 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -12096,9 +12096,9 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { }) require.NoError(t, err) - // basic fleet maintained app + // basic fleet maintained app with install script override softwareToInstall = []*fleet.SoftwareInstallerPayload{ - {Slug: &maintained1.Slug}, + {Slug: &maintained1.Slug, InstallScript: "echo 'Hello world'"}, } s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse) packages = waitBatchSetSoftwareInstallersCompleted(t, s, "", batchResponse.RequestUUID) @@ -12107,6 +12107,11 @@ func (s *integrationEnterpriseTestSuite) TestBatchSetSoftwareInstallers() { require.NotNil(t, packages[0].URL) require.Nil(t, packages[0].TeamID) + var softwareTitleResponse getSoftwareTitleResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *packages[0].TitleID), nil, http.StatusOK, &softwareTitleResponse, "teamId", "0") + require.Equal(t, "echo 'Hello world'", softwareTitleResponse.SoftwareTitle.SoftwarePackage.InstallScript) + require.Contains(t, softwareTitleResponse.SoftwareTitle.SoftwarePackage.UninstallScript, "LOGGED_IN_USER") // should pass through FMA script + // with a team s.DoJSON("POST", "/api/latest/fleet/software/batch", batchSetSoftwareInstallersRequest{Software: softwareToInstall}, http.StatusAccepted, &batchResponse, "team_name", tm.Name) packages = waitBatchSetSoftwareInstallersCompleted(t, s, tm.Name, batchResponse.RequestUUID)